Back to "Un componente di visualizzazione di Paging ASP.NET Core Tag Helper (Parte 1 le Bare-Bones)"

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

Un componente di visualizzazione di Paging ASP.NET Core Tag Helper (Parte 1 le Bare-Bones)

Tuesday, 11 March 2025

Introduzione

Un progetto di lavoro l'altro giorno ha richiesto l'attuazione dei risultati del modulo di ricerca. Il mio aiutante di tag di chiamata è sempre stato il Tag helper paginazione di Darrel O'Neill come ho scritto circa qui ma per qualsiasi motivo è solo smesso di funzionare Per me. Quindi, invece di cercare di passare attraverso quello che sembra un progetto abbandonato a questo punto ho deciso di costruirne uno io stesso.

Come al solito si può ottenere la fonte per questo sul mio GitHub

Ho un sito di esempio per questo progetto ospitato qui

Questo ha dei campioni dell'output come questo:

Cerca nella demo con Tag Helperg

Requisiti

Per questo aiutante di tag avevo alcuni requisiti:

  1. Dovrebbe funzionare senza soluzione di continuità con Vento posteriore e DaisyUICity name (optional, probably does not need a translation); i miei framework CSS preferiti.
  2. Dovrebbe funzionare con HTMX senza causare problemi.
  3. Dovrebbe avere un menu a discesa che utilizza HTMX per girare (quindi se non si utilizza HTMX dovrebbe ancora funzionare, ma è necessario aggiungere un pulsante).
  4. Dovrebbe essere facile da configurare e usare
    1. Accetta un modello di paging quindi è semplice da usare in una pagina di Rasoio
    2. Dovrebbe essere possibile configurare con alcuni semplici parametri
  5. Dovrebbe essere un pacchetto nuget in modo che tutti voi grandi persone possono giocare con esso.
  6. Dovrebbe essere entrambi un VisualizzaComponent e un TagHelper in modo che possa essere utilizzato sia in pagine Razor e viste; con questo in mente dovrebbe anche avere un sovrascrivibile Dafault.cshtml vista.
  7. Funziona con una semplice funzione di ricerca.

In futuro aggiungerò la capacità a:

  1. Aggiungi CSS personalizzato per evitare di essere legato a DaisyUI e Tailwind~~ Ho aggiunto questa capacità già vedere la demo qui: https://taghelpersample.mostlylucid.net/Home/PlainView
  2. La possibilità di specificare le dimensioni delle pagine
  3. La possibilità di aggiungere una chiamata JS personalizzata alla dimensione della pagina a discesa (per consentire di NON utilizzare HTMX).
  4. Utilizzare Alpine per rendere il cercapersone più attivo e reattivo (come ho fatto nel mio Articolo precedente).

Installazione

Il taghelper è ora un nuovo pacchetto Nuget lucido in modo da poterlo installare con il seguente comando:

dotnet add package mostlylucid.pagingtaghelper

Aggiungi il tag helper al tuo _ViewImports.cshtml file in questo modo:

@addTagHelper *, mostlylucid.pagingtaghelper

Poi si può semplicemente iniziare a usarlo; Io fornire alcune classi helper che si può usare per configre come

IPagingModel

Questa è la 'roba di base' che dovete iniziare. Si tratta di una semplice interfaccia che è possibile implementare sul vostro modello per ottenere il lavoro di paging. Notare che ViewType è opzionale qui di default a TailwindANdDaisy ma puoi impostarlo a Custom, Plain oppure Bootstrap se si desidera utilizzare una vista diversa.

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

Oppure puoi anche specificare una vista personalizzata usando il TagHelper's use-local-view proprieta'.

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

Li ho anche messi in atto nel progetto per fornire una base di riferimento:

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

Coprirò la funzionalità di ricerca in un futuro articolo..

Il tagHelper

Allora ho calcolato che cosa vorrei che il TagHelper sembri in 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>

Qui potete vedere che ho impostato alcuni parametri HTMX e il modello da utilizzare per il paging. Ho anche impostato il numero di pagine da visualizzare e le intestazioni da inviare con la richiesta (questo mi permette di utilizzare HTMX per popolare la pagina).

Il componente ha anche un BUNCH di altri elementi di configurazione che lavorerò attraverso in futuri articoli. Come potete vedere c'è un sacco di configurazione possibile.

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

Il TagHelper è abbastanza semplice ma ha una serie di proprietà che consentono all'utente di personalizzare il comportamento (si può vedere questo qui sotto nel vista ) a parte le proprietà (che non incollerò qui per brevità) il codice è abbastanza semplice:

    /// <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);
    }

Esso comprende le seguenti fasi:

  1. Imposta il nome del tag di uscita a div; questo è il contenitore per il cercapersone.
  2. Rimuovere tutte le proprietà che non sono necessarie per il contenuto reso (ma lasciare tutti gli utenti forniti, questo consente una semplice personalizzazione).
  3. Impostare il pagerId a un GUID casuale se non fornito (questo è davvero utilizzato per il codice personalizzato, è possibile specificare l'ID o semplicemente lasciare che questo codice prendersi cura di esso).
  4. Imposta il linkUrl al percorso corrente se non è fornito - questo ti permette di sovrascriverlo se vuoi usare un URL diverso.
  5. Impostare PageSize, Page, ViewType, TotalItems e SearchTerm al modello se fornito o il predefinito se non lo è. Questo ci permette di passare semplicemente nel IPagingModel e avere il pager lavorare senza ulteriori configurazioni.
  6. Imposta l'attributo ID al pagerId.
  7. Ottieni il ViewComponentHelper dal contenitore DI e contestualizzalo con l'attuale ViewContext.
  8. Crea un nuovo PagerViewModel con le proprietà impostate ai valori che abbiamo o le impostazioni predefinite se non fornite.
  9. Invoca la Pager VisualizzaComponente con il PagerViewModel e impostare il contenuto di output al risultato.

Di nuovo tutto abbastanza semplice.

The ViewComponent

La vista

La vista per il VisualizzaComponente è abbastanza semplice; è solo un ciclo attraverso le pagine e alcuni link alla prima, ultima, prossima e precedente pagine.

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>
}
Questo è suddiviso in alcune sezioni:
  1. Il menu a discesa dimensione pagina
  2. Il primo, ultimo, successivo e precedente link
  3. Il salto indietro e saltare i collegamenti in avanti
  4. I collegamenti della pagina
  5. La pagina info testo

La dimensione della pagina a discesa

Una cosa che mi mancava dal tag helper originale era una dimensione a discesa pagina. Questo è un semplice elenco di selezione, si può vedere che inizio prima definendo fixedSteps che sono solo alcuni passi fissi che voglio usare per il menu a discesa. Poi loop attraverso questi e aggiungerli alla lista. Un'abitudine che ho sempre è avere un'opzione 'tutto' quindi aggiungo gli elementi totali alla lista se non è già lì.

@{
    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);
            }
        }
    }
}

Renderò questo fuori alla pagina

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

Potete vedere che opzionalmente uso alcuni attributi HTMX per passare la dimensione della pagina al server e aggiornare la pagina mantenendo la pagina corrente e il parametro di ricerca (se presente).

Inoltre se si specif use-htmx=false come parametro sull'helper tag che non eseguirà questi ma invece ti permetterà di usare alcuni JS che fornisco come Helper HTML per aggiornare la dimensione della pagina.

@Html.PageSizeOnchangeSnippet()
    

Questo è un semplice script che aggiornerà la dimensione della pagina e ricaricare la pagina (nota che questo non funziona ancora per Plain CSS / Bootstrap come ho bisogno di elaborare i nomi delle proprietà ecc).

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();
        }
    });
});

Integrazione HTMX

L'integrazione di HTMX è abbastanza semplice poiché HTMX è in cascata agli elementi figli, possiamo definire i parametri HTMX sull'elemento genitore e saranno ereditati.

  • hx-boost="true" - questo usa il nifty funzione hx-boost per intercettare l'evento click e inviare la richiesta tramite HTMX.
  • hx-indicator="#loading-modal" - questo ha un modo di caricamento che mostrerà durante l'elaborazione della richiesta.
  • hx-target="#user-list" - questo è l'elemento che la risposta si scambierà, in questo caso l'elenco degli utenti. Nota: questo include attualmente il Pager per la semplicità; è possibile rendere questo più attivo utilizzando Alpine (come nel mio articolo precedente ) ma questa volta era fuori portata.
  • hx-swap="show:none"

Il modulo di caricamento

Questo è abbastanza semplice e utilizza DaisyUI, boxicons e Tailwind per creare un semplice modo di caricamento.

<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" specifica quindi che quando viene eseguita una richiesta HTMX mostra quindi shoould nascondere questo modo.

Caratteristiche future

Quindi questa è la parte 1, c'è ovviamente un sacco di più da coprire e lo farò in futuri articoli; compreso il sito campione, viste alternative, la funzionalità di ricerca e il CSS personalizzato.

logo

©2024 Scott Galloway