Un composant de visionnage ASP.NET Aide à l'étiquette de base (partie 1 de la Bare-Bones) (Français (French))

Un composant de visionnage ASP.NET Aide à l'étiquette de base (partie 1 de la Bare-Bones)

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

Présentation

Un projet de travail l'autre jour a nécessité la mise en oeuvre des résultats du formulaire de téléappel. J'ai toujours été l'assistant de l'étiquette d'accès à la pagination. Pagination Tag Helper par Darrel O'Neill comme je l'ai écrit sur Ici. mais pour quelque raison que ce soit, c'est juste arrêté de travailler pour moi. Donc, au lieu d'essayer de puzzler à travers ce qui ressemble à un projet abandonné à ce stade, j'ai décidé de le construire moi-même.

Comme d'habitude, vous pouvez obtenir la source pour cela sur mon GitHub

J'ai un exemple de site pour ce projet hébergé ici

Ceci a des échantillons de la sortie comme ceci:

Démo de recherche avec Tag Helperg

Besoins

Pour ce tag helper, j'avais quelques exigences :

  1. Doit fonctionner de manière transparente avec Le vent de queue et DaisyUI; mes frameworks CSS préférés.
  2. Doit travailler avec HTMX sans causer de problèmes.
  3. Si vous n'utilisez pas HTMX, il devrait encore fonctionner, mais vous devez ajouter un bouton.
  4. Doit être facile à configurer et à utiliser
    1. Accepte un modèle de téléappel afin qu'il soit simple à utiliser dans une page Razor
    2. Doit pouvoir être configuré avec quelques paramètres simples
  5. Ça devrait être un paquet nuget pour que tout le monde puisse jouer avec.
  6. Doit être les deux un ViewComponent et un TagHelper afin qu'il puisse être utilisé à la fois dans les pages de Razor et les vues; avec cela à l'esprit, il devrait également avoir un surridable Dafault.cshtml vue.
  7. Fonctionne avec une fonction de recherche simple.

À l'avenir, j'ajouterai la capacité :

  1. Ajouter CSS personnalisé pour éviter d'être lié à DaisyUI et Tailwind~~~ J'ai ajouté cette capacité déjà voir la démo ici: https://taghelpersample.mostlylucid.net/Home/PlainView
  2. La possibilité de spécifier la taille des pages
  3. La possibilité d'ajouter un appel JS personnalisé au menu déroulant de la taille de la page (pour vous permettre de NE PAS utiliser HTMX).
  4. Utilisez Alpine pour rendre le pager plus actif et plus réactif (comme je l'ai fait dans mon article précédent).

Installation

Le taghelper est maintenant un nouveau paquet Nuget brillant afin que vous puissiez l'installer avec la commande suivante:

dotnet add package mostlylucid.pagingtaghelper

Vous ajouteriez ensuite le tag helper à votre _ViewImports.cshtml fichier comme ceci :

@addTagHelper *, mostlylucid.pagingtaghelper

Ensuite, vous pouvez commencer à l'utiliser ; je fournit quelques classes d'aide que vous pouvez utiliser pour le confisquer comme

IPagingModel

C'est la "matière de base" dont tu as besoin pour commencer. C'est une interface simple que vous pouvez implémenter sur votre modèle pour obtenir le travail de pagination. Notez que ViewType est optionnel ici il est par défaut à TailwindANdDaisy Mais vous pouvez le mettre à Custom, Plain ou Bootstrap si vous voulez utiliser une vue différente.

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

OU vous pouvez même spécifier une vue personnalisée en utilisant les TagHelper use-local-view propriété.

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

J'ai également mis en œuvre ces mesures dans le cadre du projet afin de fournir une base de référence :

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

Je vais couvrir la fonctionnalité de recherche dans un futur article..

Le TagHelper

J'ai ensuite élaboré ce que j'aimerais que le TagHelper ressemble à l'utilisation:

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

Ici vous pouvez voir que j'ai défini quelques paramètres HTMX et le modèle à utiliser pour la recherche. J'ai également défini le nombre de pages à afficher et les en-têtes à envoyer avec la requête (ce qui me permet d'utiliser HTMX pour remplir la page).

Le composant a également un BUNCH d'autres éléments de configuration que je vais travailler à travers dans les futurs articles. Comme vous pouvez le voir, il y a un LOT de configuration possible.

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

Le TagHelper est assez simple mais a un tas de propriétés permettant à l'utilisateur de personnaliser le comportement (vous pouvez voir ceci ci-dessous dans le vue ) outre les propriétés (que je ne collerai pas ici pour la brièveté) le code est assez simple:

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

Il comprend les étapes suivantes:

  1. Définit le nom de la balise de sortie à div; c'est le conteneur pour le bipeur.
  2. Supprimez toutes les propriétés qui ne sont pas nécessaires pour le contenu rendu (mais laissez tous les utilisateurs fournis, cela permet une personnalisation simple).
  3. Définissez le pagerId à un GUID aléatoire s'il n'est pas fourni (ceci est vraiment utilisé pour le code personnalisé, vous pouvez spécifier l'ID ou simplement laisser ce code s'en occuper).
  4. Définissez le lienUrl sur le chemin courant s'il n'est pas fourni - cela vous permet de le surcharger si vous voulez utiliser une URL différente.
  5. Définissez la PageSize, Page, ViewType, TotalItems et SearchTerm au modèle si fourni ou par défaut si non. Cela nous permet de passer juste dans le IPagingModel et que le pager fonctionne sans autre configuration.
  6. Définissez l'attribut ID dans le pagerId.
  7. Get the ViewComponentHelper from the DI container and contextualize it with the current ViewContext.
  8. Créer un nouveau PagerViewModel avec les propriétés définies aux valeurs que nous avons ou les valeurs par défaut si elles ne sont pas fournies.
  9. Invoquer le Pager AffichageComponent avec le PagerViewModel et définissez le contenu de sortie au résultat.

Encore une fois, c'est assez simple.

Le composant de vue

La vue

La vue pour le ViewComponent est assez simple ; c'est juste une boucle à travers les pages et quelques liens vers la première, la dernière, la suivante et les pages précédentes.

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>
}
Ceci est divisé en quelques sections:
  1. La taille de la page déroulante
  2. Le premier, le dernier, le suivant et les liens précédents
  3. Le saut en arrière et sauter les liens vers l'avant
  4. Les liens de la page
  5. Le texte de la page info

La taille de la page déroulante

Une chose que je manquais de l'aide d'étiquette originale était une page déroulante de taille. Il s'agit d'une liste de sélection simple, vous pouvez voir que je commence d'abord par définir fixedSteps qui ne sont que quelques étapes fixes que je veux utiliser pour la liste déroulante. Ensuite, j'en boucle et je les ajoute à la liste. Une habitude que j'ai toujours est d'avoir une option 'tout' donc j'ajoute le total des éléments à la liste si elle n'est pas déjà 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);
            }
        }
    }
}

Je rends ensuite ceci sur la page

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

Vous pouvez voir que j'utilise en option certains attributs HTMX pour passer la taille de la page au serveur et mettre à jour la page tout en conservant la page actuelle et le paramètre de recherche (le cas échéant).

En outre, si vous spécif use-htmx=false en tant que paramètre sur l'assistant de balise, il ne sortira pas ceux-ci, mais vous permettra plutôt d'utiliser certains JS que je fournit en tant qu'assistant HTML pour mettre à jour la taille de la page.

@Html.PageSizeOnchangeSnippet()
    

Il s'agit d'un script simple qui mettra à jour la taille de la page et rechargera la page (notez que cela ne fonctionne pas encore pour Plain CSS / Bootstrap que j'ai besoin d'élaborer les noms de propriété 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();
        }
    });
});

Intégration HTMX

L'intégration HTMX est assez simple car HTMX est en cascade vers des éléments pour enfants, nous pouvons définir les paramètres HTMX sur l'élément parent et ils seront hérités.

  • hx-boost="true" - cela utilise la nifty Fonction hx-boost pour intercepter l'événement de clic et envoyer la demande via HTMX.
  • hx-indicator=#loading-modal" - il y a un mode de chargement qui s'affichera pendant que la demande est en cours de traitement.
  • hx-target=#user-list" - c'est l'élément que la réponse va échanger, dans ce cas la liste des utilisateurs. Note: Ceci inclut actuellement le Pager pour la simplicité; vous pouvez le rendre plus actif en utilisant Alpine (comme dans mon article précédent ) mais c'était hors de portée cette fois.
  • hx-swap="montrer:none"

Le mode de chargement

C'est assez simple et utilise DaisyUI, boxicons et Tailwind pour créer un mode de chargement simple.

<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" spécifie alors que lorsqu'une requête HTMX est exécutée, elle devrait s'afficher puis masquer ce modal.

Caractéristiques futures

Donc c'est la partie 1, il y a évidemment un LOT plus à couvrir et je le ferai dans les futurs articles ; y compris le site exemple, les vues alternatives, la fonctionnalité de recherche et le CSS personnalisé.

logo

©2024 Scott Galloway