En sökvy komponent ASP.NET Core Tag Helper (Del 1 Bare-Bones) (Svenska (Swedish))

En sökvy komponent ASP.NET Core Tag Helper (Del 1 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

Inledning

Ett arbetsprojekt häromdagen gjorde det nödvändigt att genomföra resultat från personsökningsformuläret. Min personsökningshjälpare har alltid varit paginering Tag Helper av Darrel O'Neill som jag skrev om här men av någon anledning är det bara slutade fungera För mig. Så istället för att försöka gå igenom vad som ser ut som ett övergivet projekt just nu bestämde jag mig för att bygga ett själv.

Som vanligt kan du få källan till detta på min GitHub

Jag har en provplats för det här projektet. värdvärd här

Detta har prover av produktionen så här:

Sök demo med tagghjälp

Krav

För denna tagghjälpare hade jag några krav:

  1. Ska fungera sömlöst med Vindrutetorkare och DaisyUI Ordförande; min föredragna CSS ramar.
  2. Ska arbeta med HTMX utan att orsaka några problem.
  3. Bör ha en rullgardin i sidstorlek som använder HTMX för att vända (så om du inte använder HTMX bör det fortfarande fungera, men du måste lägga till en knapp).
  4. Bör vara lätt att konfigurera och använda
    1. Accepterar en personsökning modell så det är enkelt att använda på en Razor sida
    2. Bör kunna konfigureras med några enkla parametrar
  5. Borde vara ett nugge paket så att alla ni stora människor kan leka med det.
  6. Bör vara BÅDE en ViewComponent och en TagHelper så att det kan användas i både Razor sidor och vyer; med det i åtanke bör det också ha en overridable Dafault.cshtml Visa.
  7. Fungerar med en enkel sökfunktion.

I framtiden ska jag lägga till förmågan att:

  1. Lägg till anpassade CSS för att undvika att bindas till DaisyUI och Tailwind ~ ~ Jag har lagt till denna förmåga redan se demo här: https://taghelper sample.melylucid.net/Home/PlainView
  2. Förmågan att ange sidstorlekar
  3. Möjligheten att lägga till ett anpassat JS-anrop i rullgardinsmenyn (för att låta dig INTE använda HTMX).
  4. Använd Alperna för att göra personsökaren mer aktiv och lyhörd (som jag gjorde i min tidigare artikel).

Anläggning

Taghelpern är nu ett skinande nytt Nuget-paket så att du kan installera det med följande kommando:

dotnet add package mostlylucid.pagingtaghelper

Du skulle sedan lägga taggen hjälpare till din _ViewImports.cshtml Filen så här:

@addTagHelper *, mostlylucid.pagingtaghelper

Då kan du bara börja använda det; Jag ger några hjälpare klasser som du kan använda för att konfigurera det som

IPagingModel

Detta är de "grundläggande sakerna" du behöver för att komma igång. Det är ett enkelt gränssnitt som du kan implementera på din modell för att få personsökningen att fungera. Lägg märke till att ViewType är valfritt här det förvalt till TailwindANdDaisy Men du kan ställa in det till Custom, Plain eller Bootstrap om du vill använda en annan vy.

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

ELLER så kan du till och med ange en egen vy genom att använda TagHelper's use-local-view egendom.

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

Jag har också genomfört dessa i projektet för att tillhandahålla en baslinje:

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

Jag täcker sökfunktionen i en kommande artikel..

Tagghjälparen

Jag räknade sedan ut hur jag skulle vilja att TagHelper att se ut i bruk:

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

Här kan du se att jag ställer in några HTMX parametrar och modellen att använda för personsökning. Jag ställde också in antalet sidor att visa och rubrikerna att skicka med begäran (detta gör att jag kan använda HTMX för att fylla sidan).

Komponenten har också en BUNCH av andra konfigurationselement som jag kommer att arbeta igenom i kommande artiklar. Som ni kan se finns det en massa möjliga konfigurationer.

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

TagHelper är ganska enkel men har en massa egenskaper som gör det möjligt för användaren att anpassa beteendet (du kan se detta nedan i Visa ) bortsett från egenskaperna (som jag inte kommer att klistra här för korthet) koden är ganska enkel:

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

Den omfattar följande steg:

  1. Ställ in utmatningstaggens namn till div; Detta är behållaren för sökaren.
  2. Ta bort alla egenskaper som inte behövs för det renderade innehållet (men lämna alla användare som tillhandahålls, detta gör det möjligt för enkel anpassning).
  3. Ställ in sidans ID till ett slumpmässigt GUID om det inte tillhandahålls (detta används verkligen för anpassad kod, du kan ange ID eller bara låta denna kod ta hand om det).
  4. Ställ in länkUrl till aktuell sökväg om den inte tillhandahålls - det låter dig åsidosätta detta om du vill använda en annan webbadress.
  5. Ställ in sidstorlek, sida, visningstyp, totala objekt och sökterm till modellen om den tillhandahålls eller standard om inte. Detta gör det möjligt för oss att bara passera i IPagingModel och få sökaren att arbeta utan ytterligare konfiguration.
  6. Ställ in id- attributet till personsökaren.
  7. Hämta ViewComponentHelper från DI-behållaren och kontextualisera den med den aktuella ViewContext.
  8. Skapa en ny PagerViewModel med de egenskaper som är inställda på de värden vi har eller standardvärden om de inte tillhandahålls.
  9. Uppmana Pager VisaKomponent med PagerViewModel och ställa in utdatainnehållet till resultatet.

Återigen är allt ganska enkelt.

VisaKomponenten

Vyn

Vyn för VisaKomponenten är ganska enkel; det är bara en loop genom sidorna och några länkar till den första, sista, nästa och föregående sidor.

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>
}
Detta delas upp i några avsnitt:
  1. Dropdown för sidstorlek
  2. De första, sista, nästa och föregående länkarna
  3. Hoppa tillbaka och hoppa framåt länkar
  4. Sidlänkar
  5. Sidans informationstext

Sidstorleksdroppar

En sak jag saknade från den ursprungliga taggen hjälpare var en sida storlek dropdown. Det här är en enkel lista, du kan se att jag börjar med att definiera fixedSteps som bara är några fasta steg jag vill använda för dropdown. Jag slingar sedan igenom dessa och lägger till dem på listan. En vana jag alltid har är att ha ett "allt" alternativ så jag lägger till det totala objektet i listan om det inte redan finns där.

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

Jag ger sedan ut detta till sidan

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

Du kan se att jag tillval använder vissa HTMX-attribut för att skicka sidstorleken till servern och uppdatera sidan samtidigt som du behåller den aktuella sidan och sökparametern (om sådan finns).

Dessutom om du anger use-htmx=false som en parameter på tagghjälparen det kommer inte att mata ut dessa men istället kommer att tillåta dig att använda några JS jag tillhandahåller som en HTML-hjälpare för att uppdatera sidstorleken.

@Html.PageSizeOnchangeSnippet()
    

Detta är ett enkelt skript som kommer att uppdatera sidstorleken och ladda om sidan (observera att detta ännu inte fungerar för Plain CSS / Bootstrap som jag behöver för att räkna ut fastighetsnamn 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();
        }
    });
});

Integrering av HTMX

Den HTMX integration är ganska enkel som HTMX är kaskad till barn element vi kan definiera HTMX parametrar på förälder elementet och de kommer att ärvs.

  • hx-boost="true" - det här använder niffy Hx-boost-funktionName för att stoppa klickhändelsen och skicka begäran via HTMX.
  • hx-indicator="#loading-modal" - detta har en lastningsmetod som kommer att visa medan begäran behandlas.
  • hx-target="#användarlista" - detta är det element som svaret kommer att byta ut, i detta fall användarlistan. Obs: Detta inkluderar för närvarande Pager för enkelhet; du KAN göra detta mer aktivt med Alpine (som i min tidigare artikel ) men det var ogenomförbart den här gången.
  • Hx-swap="show:none"

Loadingmodalen

Detta är ganska enkelt och använder DaisyUI, boxicons och Tailwind för att skapa en enkel lastning modal.

<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" anger sedan att när en HTMX-begäran utförs ska den visas och därefter dölja denna modal.

Framtida egenskaper

Så det är del 1, det finns uppenbarligen en LOT mer att täcka och jag kommer att i framtida artiklar; inklusive provplatsen, alternativa vyer, sökfunktionen och anpassade CSS.

logo

©2024 Scott Galloway