A Paging View Component ASP.NET Core Tag Helper (Parte 1 de los Bonos Descalzos) (Español (Spanish))

A Paging View Component ASP.NET Core Tag Helper (Parte 1 de los Bonos Descalzos)

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.

Tuesday, 11 March 2025

//

Less than a minute

Introducción

Un proyecto de trabajo el otro día requirió la implementación de resultados de formulario de búsqueda. Mi ayudante de paginación siempre ha sido el Ayudante de etiqueta de paginación por Darrel O'Neill como escribí sobre aquí Sin embargo, por cualquier razón que sea es sólo dejó de funcionar para mí. Así que en lugar de tratar de resolver lo que parece un proyecto abandonado en este punto decidí construir uno yo mismo.

Como de costumbre se puede obtener la fuente para esto en mi GitHub

Tengo un sitio de muestra para este proyecto. hospedado aquí

Esto tiene muestras de la salida como esta:

Demostración de búsqueda con ayuda de etiqueta

Necesidades

Para este ayudante de etiqueta tenía algunos requisitos:

  1. Debe funcionar sin problemas con Viento de cola y DaisyUI; mis marcos CSS preferidos.
  2. Debe trabajar con HTMX sin causar ningún problema.
  3. Debería tener un menú desplegable de pagesize que use HTMX para voltear (así que si no usas HTMX todavía debería funcionar pero necesitas añadir un botón).
  4. Debe ser fácil de configurar y usar
    1. Acepta un modelo de paginación por lo que es fácil de usar en una página de Razor
    2. Debe ser capaz de ser configurado con unos pocos parámetros simples
  5. Debería ser un paquete de pepitas para que todos los grandes puedan jugar con él.
  6. Debe ser tanto un ViewComponent como un TagHelper para que pueda ser utilizado en las páginas y vistas de Razor; con eso en mente, también debe tener una sobrerregulable Dafault.cshtml vista.
  7. Funciona con una simple función de búsqueda.

En el futuro añadiré la capacidad a:

  1. Agregue CSS personalizado para evitar estar atado a DaisyUI y Tailwind. He añadido esta capacidad ya ver la demo aquí: https://taghelpersample.mostlylucid.net/Home/PlainView
  2. La capacidad de especificar los tamaños de página
  3. La posibilidad de añadir una llamada JS personalizada al menú desplegable del tamaño de la página (para permitir que NO use HTMX).
  4. Utilice Alpine para hacer que el buscapersonas más activo y sensible (como lo hice en mi Artículo anterior).

Instalación

El taghelper es ahora un nuevo paquete Nuget brillante para que pueda instalarlo con el siguiente comando:

dotnet add package mostlylucid.pagingtaghelper

A continuación, añadir el ayudante de la etiqueta a su _ViewImports.cshtml file like so:

@addTagHelper *, mostlylucid.pagingtaghelper

A continuación, sólo puede empezar a utilizarlo; Proporciono algunas clases de ayuda que se puede utilizar para configurar como

IPagingModel

Esta es la 'cosa básica' que necesitas para empezar. Es una interfaz sencilla que puedes implementar en tu modelo para que la paginación funcione. Tenga en cuenta que ViewType es opcional aquí por defecto a TailwindANdDaisy pero usted puede configurarlo a Custom, Plain o Bootstrap si quieres usar una vista diferente.

public enum ViewType
{
TailwindANdDaisy,
Custom,
Plain,
Bootstrap
}

O incluso puede especificar una vista personalizada usando el TagHelper's use-local-view propiedad.

namespace mostlylucid.pagingtaghelper.Models;

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; }
}
namespace mostlylucid.pagingtaghelper.Models;

public interface IPagingSearchModel : IPagingModel
{
    public string? SearchTerm { get; set; }
}

También he aplicado estas medidas en el proyecto para proporcionar una base de referencia:

public abstract class BasePagerModel : IPagingModel
{
    public int Page { get; set; } = 1;
    public int TotalItems { get; set; } = 0;
    public int PageSize { get; set; } = 10;
    public ViewType ViewType { get; set; } = ViewType.TailwindANdDaisy;

    public string LinkUrl { get; set; } = "";

}
public abstract class BasePagerSearchMdodel : BasePagerModel, IPagingSearchModel
{
    public string? SearchTerm { get; set; }
}

Cubriré la funcionalidad de búsqueda en un artículo futuro..

El ayudante de etiquetas

Luego me di cuenta de lo que me gustaría que el TagHelper se vea en uso:

<paging
    x-ref="pager"
    hx-boost="true"
    hx-indicator="#loading-modal"
    hx-target="#user-list "
    hx-swap="show:none"
    model="Model"
    pages-to-display="10"
    hx-headers='{"pagerequest": "true"}'>
</paging>

Aquí puede ver que configuro algunos parámetros HTMX y el modelo a usar para la paginación. También configuro el número de páginas a mostrar y los encabezados a enviar con la solicitud (esto me permite usar HTMX para poblar la página).

El componente también tiene un BUNCH de otros elementos de configuración que trabajaré en futuros artículos. Como se puede ver Therer es una gran cantidad de configuración posible.

<paging css-class=""
        first-last-navigation=""
        first-page-text=""
        next-page-aria-label=""
        next-page-text=""
        page=""
        pages-to-display=""
        page-size=""
        previous-page-text=""
        search-term=""
        skip-forward-back-navigation=""
        skip-back-text=""
        skip-forward-text="true"
        total-items=""
        link-url=""
        last-page-text=""
        show-pagesize=""
        use-htmx=""
        use-local-view=""
        view-type="Bootstrap"
        htmx-target=""
        id=""
></paging>

El TagHelper es bastante simple, pero tiene un montón de propiedades que permiten al usuario personalizar el comportamiento (puede ver esto a continuación en el vista ) aparte de las propiedades (que no pegaré aquí para la brevedad) el código es bastante sencillo:

    /// <summary>
    /// Processes the tag helper to generate the pagination component.
    /// </summary>

    /// <param name="context">The tag helper context.</param>
    /// <param name="output">The tag helper output.</param>
    public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
   
        output.TagName = "div";
        
        //Remove all the properties that are not needed for the rendered content.
        output.Attributes.RemoveAll("page");
        output.Attributes.RemoveAll("link-url");
        output.Attributes.RemoveAll("page-size");
        output.Attributes.RemoveAll("total-items");
        output.Attributes.RemoveAll("pages-to-display");
        output.Attributes.RemoveAll("css-class");
        output.Attributes.RemoveAll("first-page-text");
        output.Attributes.RemoveAll("previous-page-text");
        output.Attributes.RemoveAll("skip-back-text");
        output.Attributes.RemoveAll("skip-forward-text");
        output.Attributes.RemoveAll("next-page-text");
        output.Attributes.RemoveAll("next-page-aria-label");
        output.Attributes.RemoveAll("last-page-text");
        output.Attributes.RemoveAll("first-last-navigation");
        output.Attributes.RemoveAll("skip-forward-back-navigation");
        output.Attributes.RemoveAll("model");
        output.Attributes.RemoveAll("show-pagesize");
        output.Attributes.RemoveAll("pagingmodel");
        output.Attributes.RemoveAll("use-local-view");
        
        var pagerId =  PagerId ?? $"pager-{Guid.NewGuid():N}";
        var linkUrl = LinkUrl ?? ViewContext.HttpContext.Request.Path;
        PageSize = Model?.PageSize ?? PageSize ?? 10;
        Page = Model?.Page ?? Page ?? 1;
        ViewType = Model?.ViewType ?? ViewType;
        TotalItems = Model?.TotalItems ?? TotalItems ?? 0;
        if(Model is IPagingSearchModel searchModel)
            SearchTerm = searchModel?.SearchTerm ?? SearchTerm ?? "";
        output.Attributes.SetAttribute("id", pagerId);
        var viewComponentHelper = (IViewComponentHelper)ViewContext.HttpContext.RequestServices.GetService(typeof(IViewComponentHelper))!;
        ((IViewContextAware)viewComponentHelper).Contextualize(ViewContext);

        var pagerModel = PagerModel ?? new PagerViewModel()
        {
            
            ViewType = ViewType,
            UseLocalView = UseLocalView,
            UseHtmx = UseHtmx,
            PagerId = pagerId,
            SearchTerm = SearchTerm,
            ShowPageSize = ShowPageSize,
            Model = Model,
            LinkUrl = linkUrl,
            Page = Page,
            PageSize = PageSize,
            TotalItems = TotalItems,
            PagesToDisplay = PagesToDisplay,
            CssClass = CssClass,
            FirstPageText = FirstPageText,
            PreviousPageText = PreviousPageText,
            SkipBackText = SkipBackText,
            SkipForwardText = SkipForwardText,
            NextPageText = NextPageText,
            NextPageAriaLabel = NextPageAriaLabel,
            LastPageText = LastPageText,
            FirstLastNavigation = FirstLastNavigation,
            SkipForwardBackNavigation = SkipForwardBackNavigation,
            HtmxTarget = HtmxTarget,
            
        };

        var result = await viewComponentHelper.InvokeAsync("Pager", pagerModel);
        output.Content.SetHtmlContent(result);
    }

Comprende las siguientes medidas:

  1. Establecer el nombre de la etiqueta de salida a div; este es el contenedor para el buscapersonas.
  2. Eliminar todas las propiedades que no son necesarias para el contenido renderizado (pero dejar todos los usuarios proporcionados, esto permite una simple personalización).
  3. Configure el pagerId en un GUID aleatorio si no se proporciona (esto se utiliza realmente para el código personalizado, puede especificar el ID o simplemente dejar que este código se ocupe de él).
  4. Configure el enlace Url a la ruta actual si no se proporciona - esto le permite anular esto si desea utilizar una URL diferente.
  5. Configure el tamaño de la página, la página, el tipo de vista, TotalItems y SearchTerm en el modelo si se proporciona o el predeterminado si no. Esto nos permite simplemente pasar en el IPagingModel y hacer que el buscapersonas funcione sin más configuración.
  6. Define el atributo ID al pagerId.
  7. Obtenga el ViewComponentHelper del contenedor DI y contextualícelo con el ViewContext actual.
  8. Crear un nuevo PagerViewModel con las propiedades establecidas a los valores que tenemos o los valores predeterminados si no se proporcionan.
  9. Invoca la Pager VerComponent con el PagerViewModel y establecer el contenido de salida al resultado.

De nuevo todo bastante simple.

El componente de vista

La vista

La vista para el ViewComponent es bastante simple; es sólo un bucle a través de las páginas y algunos enlaces a la primera, última, siguiente y páginas anteriores.

Complete source code for the Default TailwindUI & Daisy view
@model mostlylucid.pagingtaghelper.Components.PagerViewModel
@{
    var totalPages = (int)Math.Ceiling((double)Model.TotalItems! / (double)Model.PageSize!);
    var pageSizes = new List<int>();
    if (Model.ShowPageSize)
    {
        // Build a dynamic list of page sizes.

        // Fixed steps as a starting point.
        int[] fixedSteps = { 10, 25, 50, 75, 100, 125, 150, 200, 250, 500, 1000 };

        // Add only those fixed steps that are less than or equal to TotalItems.
        foreach (var step in fixedSteps)
        {
            if (step <= Model.TotalItems)
            {
                pageSizes.Add(step);
            }
        }

        // If TotalItems is greater than the largest fixed step,
        // add additional steps by doubling until reaching TotalItems.
        if (Model.TotalItems > fixedSteps.Last())
        {
            int next = fixedSteps.Last();
            while (next < Model.TotalItems)
            {
                next *= 2;
                // Only add if it doesn't exceed TotalItems.
                if (next < Model.TotalItems)
                {
                    pageSizes.Add(next);
                }
            }

            // Always include the actual TotalItems as the maximum option.
            if (!pageSizes.Contains(Model.TotalItems.Value))
            {
                pageSizes.Add(Model.TotalItems.Value);
            }
        }
    }
}
@if (totalPages > 1)
{
    <div class="@Model.CssClass flex items-center justify-center" id="pager-container">
        @if (Model.ShowPageSize)
        {
            var pagerId = Model.PagerId;
            var htmxAttributes = Model.UseHtmx
                ? $"hx-get=\"{Model.LinkUrl}\" hx-trigger=\"change\" hx-include=\"#{pagerId} [name='page'], #{pagerId} [name='search']\" hx-push-url=\"true\""
                : "";


            <!-- Preserve current page -->
            <input type="hidden" name="page" value="@Model.Page"/>
            <input type="hidden" name="search" value="@Model.SearchTerm"/>
            <input type="hidden" class="useHtmx" value="@Model.UseHtmx.ToString().ToLowerInvariant()"/>
            if (!Model.UseHtmx)
            {
                <input type="hidden" class="linkUrl" value="@Model.LinkUrl"/>
            }

            <!-- Page size select with label -->
            <div class="flex items-center mr-8">
                <label for="pageSize-@pagerId" class="text-sm text-gray-600 mr-2">Page size:</label>
                <select id="pageSize-@pagerId"
                        name="pageSize"
                        class="border rounded select select-primary select-sm pt-0 mt-0 min-w-[80px] pr-4"
                        @Html.Raw(htmxAttributes)>
                    @foreach (var option in pageSizes.ToList())
                    {
                        var optionString = option.ToString();
                        if (option == Model.PageSize)
                        {
                            <option value="@optionString" selected="selected">@optionString</option>
                        }
                        else
                        {
                            <option value="@optionString">@optionString</option>
                        }
                    }
                </select>
            </div>
        }

        @* "First" page link *@
        @if (Model.FirstLastNavigation && Model.Page > 1)
        {
            var href = $"{Model.LinkUrl}?page=1&pageSize={Model.PageSize}";
            if (!string.IsNullOrEmpty(Model.SearchTerm))
            {
                href += $"&search={Model.SearchTerm}";
            }

            <a class="btn btn-sm"
               href="@href">
                @Model.FirstPageText
            </a>
        }

        @* "Previous" page link *@
        @if (Model.Page > 1)
        {
            var href = $"{Model.LinkUrl}?page={Model.Page - 1}&pageSize={Model.PageSize}";
            if (!string.IsNullOrEmpty(Model.SearchTerm))
            {
                href += $"&search={Model.SearchTerm}";
            }

            <a class="btn btn-sm"
               href="@href">
                @Model.PreviousPageText
            </a>
        }

        @* Optional skip back indicator *@
        @if (Model.SkipForwardBackNavigation && Model.Page > Model.PagesToDisplay)
        {
            <a class="btn btn-sm btn-disabled">
                @Model.SkipBackText
            </a>
        }

        @* Determine visible page range *@
        @{
            int halfDisplay = Model.PagesToDisplay / 2;
            int startPage = Math.Max(1, Model.Page.Value - halfDisplay);
            int endPage = Math.Min(totalPages, startPage + Model.PagesToDisplay - 1);
            startPage = Math.Max(1, endPage - Model.PagesToDisplay + 1);
        }
        @for (int i = startPage; i <= endPage; i++)
        {
            var href = $"{Model.LinkUrl}?page={i}&pageSize={Model.PageSize}";
            if (!string.IsNullOrEmpty(Model.SearchTerm))
            {
                href += $"&search={Model.SearchTerm}";
            }

            <a data-page="@i" class="btn btn-sm mr-2 @(i == Model.Page ? "btn-active" : "")"
               href="@href">
                @i
            </a>
        }

        @* Optional skip forward indicator *@
        @if (Model.SkipForwardBackNavigation && Model.Page < totalPages - Model.PagesToDisplay + 1)
        {
            <a class="btn btn-sm btn-disabled mr-2">
                @Model.SkipForwardText
            </a>
        }

        @* "Next" page link *@
        @if (Model.Page < totalPages)
        {
            var href = $"{Model.LinkUrl}?page={Model.Page + 1}&pageSize={Model.PageSize}";
            if (!string.IsNullOrEmpty(Model.SearchTerm))
            {
                href += $"&search={Model.SearchTerm}";
            }

            <a class="btn btn-sm mr-2"
               href="@href"
               aria-label="@Model.NextPageAriaLabel">
                @Model.NextPageText
            </a>
        }

        @* "Last" page link *@
        @if (Model.FirstLastNavigation && Model.Page < totalPages)
        {
            var href = $"{Model.LinkUrl}?page={totalPages}&pageSize={Model.PageSize}";
            if (!string.IsNullOrEmpty(Model.SearchTerm))
            {
                href += $"&search={Model.SearchTerm}";
            }

            <a class="btn btn-sm"
               href="@href">
                @Model.LastPageText
            </a>
        }

        <!-- Page info text with left margin for separation -->
        <div class="text-sm text-neutral-500 ml-8">
            Page @Model.Page of @totalPages (Total items: @Model.TotalItems)
        </div>
    </div>
}
Esto se divide en unas pocas secciones:
  1. El menú desplegable tamaño de página
  2. Los enlaces primero, último, siguiente y anterior
  3. El salto hacia atrás y saltar hacia adelante enlaces
  4. Los enlaces de la página
  5. El texto de información de la página

El menú desplegable del tamaño de la página

Una cosa que me faltaba en el ayudante original de la etiqueta era un menú desplegable del tamaño de la página. Esta es una simple lista de selección, se puede ver que primero empiezo por definir fixedSteps que son sólo unos pocos pasos fijos que quiero utilizar para el menú desplegable. A continuación, bucle a través de estos y añadirlos a la lista. Un hábito que siempre tengo es tener una opción "todo" por lo que añado el total de elementos a la lista si no está ya allí.

@{
    var totalPages = (int)Math.Ceiling((double)Model.TotalItems! / (double)Model.PageSize!);
    var pageSizes = new List<int>();
    if (Model.ShowPageSize)
    {
        // Build a dynamic list of page sizes.

        // Fixed steps as a starting point.
        int[] fixedSteps = { 10, 25, 50, 75, 100, 125, 150, 200, 250, 500, 1000 };

        // Add only those fixed steps that are less than or equal to TotalItems.
        foreach (var step in fixedSteps)
        {
            if (step <= Model.TotalItems)
            {
                pageSizes.Add(step);
            }
        }

        // If TotalItems is greater than the largest fixed step,
        // add additional steps by doubling until reaching TotalItems.
        if (Model.TotalItems > fixedSteps.Last())
        {
            int next = fixedSteps.Last();
            while (next < Model.TotalItems)
            {
                next *= 2;
                // Only add if it doesn't exceed TotalItems.
                if (next < Model.TotalItems)
                {
                    pageSizes.Add(next);
                }
            }

            // Always include the actual TotalItems as the maximum option.
            if (!pageSizes.Contains(Model.TotalItems.Value))
            {
                pageSizes.Add(Model.TotalItems.Value);
            }
        }
    }
}

Entonces renderizo esto a la página

  @if (Model.ShowPageSize)
        {
            var pagerId = Model.PagerId;
            var htmxAttributes = Model.UseHtmx
                ? $"hx-get=\"{Model.LinkUrl}\" hx-trigger=\"change\" hx-include=\"#{pagerId} [name='page'], #{pagerId} [name='search']\" hx-push-url=\"true\""
                : "";


            <!-- Preserve current page -->
            <input type="hidden" name="page" value="@Model.Page"/>
            <input type="hidden" name="search" value="@Model.SearchTerm"/>
            <input type="hidden" class="useHtmx" value="@Model.UseHtmx.ToString().ToLowerInvariant()"/>
            if (!Model.UseHtmx)
            {
                <input type="hidden" class="linkUrl" value="@Model.LinkUrl"/>
            }

            <!-- Page size select with label -->
            <div class="flex items-center mr-8">
                <label for="pageSize-@pagerId" class="text-sm text-gray-600 mr-2">Page size:</label>
                <select id="pageSize-@pagerId"
                        name="pageSize"
                        class="border rounded select select-primary select-sm pt-0 mt-0 min-w-[80px] pr-4"
                        @Html.Raw(htmxAttributes)>
                    @foreach (var option in pageSizes.ToList())
                    {
                        var optionString = option.ToString();
                        if (option == Model.PageSize)
                        {
                            <option value="@optionString" selected="selected">@optionString</option>
                        }
                        else
                        {
                            <option value="@optionString">@optionString</option>
                        }
                    }
                </select>
            </div>
        }

Puede ver que, opcionalmente, utilizo algunos atributos HTMX para pasar el tamaño de la página al servidor y actualizar la página manteniendo la página actual y el parámetro de búsqueda (si los hay).

Además, si especif use-htmx=false como parámetro en el ayudante de la etiqueta no saldrá de éstos, sino que le permitirá utilizar algunos JS que proveo como ayuda HTML para actualizar el tamaño de la página.

@Html.PageSizeOnchangeSnippet()
    

Este es un script simple que actualizará el tamaño de la página y recargará la página (note que esto todavía no funciona para Plain CSS / Bootstrap ya que necesito averiguar los nombres de las propiedades, etc).

document.addEventListener("DOMContentLoaded", function () {
    document.body.addEventListener("change", function (event) {
        const selectElement = event.target.closest("#pager-container select[name='pageSize']");
        if (!selectElement) return;

        const pagerContainer = selectElement.closest("#pager-container");
        const useHtmxInput = pagerContainer.querySelector("input.useHtmx");
        const useHtmx = useHtmxInput ? useHtmxInput.value === "true" : true; // default to true

        if (!useHtmx) {
            const pageInput = pagerContainer.querySelector("[name='page']");
            const searchInput = pagerContainer.querySelector("[name='search']");

            const page = pageInput ? pageInput.value : "1";
            const search = searchInput ? searchInput.value : "";
            const pageSize = selectElement.value;
            const linkUrl =  pagerContainer.querySelector("input.linkUrl").value ?? "";
            
            const url = new URL(linkUrl, window.location.origin);
            url.searchParams.set("page", page);
            url.searchParams.set("pageSize", pageSize);

            if (search) {
                url.searchParams.set("search", search);
            }

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

Integración HTMX

La integración HTMX es bastante simple, ya que HTMX está en cascada con elementos secundarios podemos definir los parámetros HTMX en el elemento padre y serán heredados.

  • hx-boost="true" - esto usa el ingenioso Función hx-boost para interceptar el evento de clic y enviar la solicitud a través de HTMX.
  • hx-indicator="#loading-modal" - esto tiene un modal de carga que se mostrará mientras se procesa la solicitud.
  • hx-target="#user-list" - este es el elemento que la respuesta intercambiará, en este caso la lista de usuarios. Nota: Esto incluye actualmente el Pager para la simplicidad; se puede hacer esto más activo utilizando Alpine (como en mi Artículo anterior ) pero estaba fuera de alcance esta vez.
  • hx-swap="show:none"

El modo de carga

Esto es bastante simple y utiliza DaisyUI, boxicons y Tailwind para crear un simple modal de carga.

<div id="loading-modal" class="modal htmx-indicator">
    <div class="modal-box flex flex-col items-center justify-center">
        <h2 class="text-lg font-semibold">Loading...</h2>
        <i class="bx bx-loader bx-spin text-3xl mt-2"></i>
    </div>
</div>

hx-indicator="#loading-modal" especifica entonces que cuando se realiza una solicitud HTMX se muestra a continuación ocultar este modal.

Características futuras

Así que esa es la parte 1, obviamente hay mucho más que cubrir y lo haré en futuros artículos, incluyendo el sitio de muestra, vistas alternativas, la funcionalidad de búsqueda y el CSS personalizado.

logo

©2024 Scott Galloway