A Paging-View-Komponente ASP.NET Core Tag Helper (Teil 2, Seitengröße) (Deutsch (German))

A Paging-View-Komponente ASP.NET Core Tag Helper (Teil 2, Seitengröße)

Comments

NOTE: Apart from English (and even then it's questionable, I'm Scottish). These are machine translated in languages I don't read. If they're terrible please contact me.
You can see how this translation was done in this article.

Monday, 17 March 2025

//

Less than a minute

Einleitung

Als Teil meines fortlaufenden Salbeis mit meinem Paging-Tag-Helfer habe ich nun PageSize in seinen eigenen Tag-Helfer getrennt. Dies soll den Tag-Helfer flexibler machen und komplexere Szenarien ermöglichen.

Wieder einmal, das sind alles Arbeiten-in-Fortschritt. Ich bin schon da. bis zu 0,9,0 zum Zeitpunkt des Schreibens, aber nutzen Sie auf Ihrem OWN RISK. Es ist kostenlos, aber NICHT YET von Release-Qualität IMHO (Ich habe nicht einmal Vollproben noch!)

Siehe Code für den Tag-Helfer hier.

Die Seitengröße Tag Helfer

Wie bei meinen anderen Taghelpern ist dies für einen Use-Case konzipiert, auf den ich oft stoße; wie man einfach die Seitengröße in Ergebnislisten wechseln kann. Auf den ersten Blick scheint dies ziemlich einfach, aber es kann TRICKY werden, sobald Sie HTMX in den Mix hinzufügen.

Anforderungen

So für meinen Anwendungsfall die Anforderungen waren die folgenden

  1. Ermöglicht eine einfache Nutzung für den Entwickler (so viel wie möglich ist mit einem einfachen Seitenmodell konfiguriert)
  2. Unterstützt die Aktualisierung der Seitengröße mit HTMX - d.h. es ist in der Lage, zurück zu posten unter Beibehaltung aller Query-String-Parameter. Dies ist wichtig, da es dem Benutzer erlaubt, die Seitengröße zu ändern, ohne andere Filter zu verlieren, die sie angewendet haben.
  3. Hat einen etwas dynamischen Bereich von Seitengrößen (einschließlich einer 'Alle' Option).

Der Tag-Helfer

In diesem Sinne habe ich den TagHelper wie folgt gestaltet:

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

Also in diesem Fall wieder mein Standard-Paging-Modell:

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

Das stellt die Page, TotalItems, PageSize, ViewType und LinkUrl für die Vorführung.

Sie können diese auch in Form von Attributen an das Modell spezifizieren:


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

Wobei die hx-target hier ist das Ziel für die HTMX-Anfrage und die hx-swap ist das Ziel für die Antwort.

In diesem Fall wird es die angegebene PageSize und TotalItems und ViewType um die Auswahl der Seitengröße zu rendern.

JS Shenanigans

Leider hatte ich diesmal eine Anforderung, die ich nicht mit rein Server-Seite umgehen konnte. Nämlich wollte ich Query-String-Paramater in der Pagesize-Änderungsanfrage beibehalten. Um dies zu erreichen, habe ich zwei Teile von JS, die in die Steuerung je nachdem, HTMX verwendet wird laden ooder nicht

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

Wenn HTMX verwendet wird, wird dies in die Seite gerendert:

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

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

Das ist ein einfaches Stück JavaScript, das aktuelle Querystring-Parameter in die Anfrage injiziert.

Wenn HTMX NICHT aktiviert ist, wird dies auch rendern:

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

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

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

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

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

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

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

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

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

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

Eine Änderung, die ich lilely machen werde, ist die Änderung des Namens der 'Flag'-Variable; __pageSizeListenerAttached da es ein bisschen zu generisch ist UND ich es sowohl für HTMX- als auch für Nicht-HTMX-Anfragen verwende.

Der CODE

Der Tag-Helfer selbst ist ziemlich einfach, der Hauptcode bevölkert nur ein paar Eigenschaften und macht dann die View Component.

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

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

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

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

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

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

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

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

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


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

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

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

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

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

Ein kniffliges Stück ist der richtige Umgang mit der PageSizeSteps Attribut. Dies ist eine durch Komma getrennte Liste der Seitengrößen, aus denen der Benutzer auswählen kann. ODER Sie können in Ihren eigenen Schritten passieren. Sie können auch die MaxPageSize angeben, die die maximale Seitengröße ist, die ausgewählt werden kann.

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

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

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

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

        pageSizes.Sort();

        return pageSizes;
    }

Wie bei meinem anderen Tag-Helfer benutze ich das TagHelper->ViewComponent-Muster, um die View-Komponente zu rendern. Dies ermöglicht es mir, den Tag Helper einfach zu halten und den View-Komponente-Komplex. Es erlaubt Ihnen auch, Ansichten für unterschiedliche Erfahrungen zu ändern; oder sogar Ihre eigene Ansicht injizieren und verwenden, dass (mit use-local-view).


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

            var useLocalView = model.UseLocalView;

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

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

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

Schlussfolgerung

Nun, das war's für den PageSize-Tag-Helfer. Ich arbeite immer noch an der Dokumentation und an Beispielen, aber der Code wird täglich besser getigt.

logo

©2024 Scott Galloway