You are currently viewing Stronicowanie w Razor Pages

Stronicowanie w Razor Pages

W aplikacjach webowych stronicowanie (ang. paging) przydaje się, kiedy szczodry serwer chce zasypać użytkownika duża ilością danych. Dużą, to jest taką, że wyświetlenie ich na raz mogło by tegoż użytkownika zniechęcić do zapoznania się z nimi. O ile jest to lista przyjętych na Wydział Informatyki UW, to pal sześć. Ale jeśli nasze dane to sklep internetowy z ofertą tysięcy oprawek do okularów (kto wybierał oprawki przez Internet, to wie o czym mówię ;), to aspektowi user experience trzeba poświęcić więcej uwagi. Aby strona nie była niczym zwój papirusu, to całą listę dzieli się na mniejsze kawałki i wyświetla w porcjach w nadziei, że klient łatwiej to zniesie. Stronicowanie ma też swoją praktyczną stronę. Pomaga ograniczać obciążenie łącza i źródła danych, bo kwerenda paginacji zwraca tylko tyle rekordów, ile mamy zamiar wyświetlić na stronie i ani jednego więcej.

ASP.NET Core nie oferowało paginacji „z pudełka”. I chyba nadal nie oferuje, bo samouczek Microsoftu dla .NET7 nadal pokazuje własną implementację. Jest ona o tyle pouczająca, co bardzo nieużyteczna. Implementacja w takiej postaci w aplikacji webowej oznacza każdorazowo modyfikację kodu z wielu miejscach. Dlatego proponuję wykonać pracę raz, ale w taki sposób, aby łatwo ją zastosować do dowolnej kolekcji. Wykonamy własny tag helper. Do wykorzystanie go w widoku, będzie potrzebna jedna linijka kodu.

Na początek potrzebujemy reprezentacji nawigacji do naszej listy. Będzie ona zawierała właściwości potrzebne do generowania klawiszy nawigacji.

public interface IPagination
{
    int CurrentPage { get; }
    int TotalPages { get; }
    int PageSize { get; }
    int TotalCount { get; }
    bool HasPrevious { get; }
    bool HasNext { get; }
}

Następnie zdefiniujemy obiekt listy. Generyczny parametr będzie można zastąpić typem odpowiednim w konkretnej aplikacji (np. GlassesFrame). Tu będzie logika odpowiedzialna za ustawienie wartości właściwości paginacji. PagedList jak na przyzwoity obiekt przystało sam zadba o utworzenie egzemplarza (linia 19, metoda Create()). Metoda bierze parametry strony (numer i ilość rekordów) oraz źródło danych jako IQueryable<T>. Interfejs IQueryable powinien być tak zaimplementowany przez Data Provider, żeby kwerenda była generowana dopiero po zdefiniowaniu zapytania Linq i wywołaniu polecenia materializacji wyników (linia 21, polecenie Count()). Oznacza to, że Linq-to-SQL przeanalizuje wyrażenie i nie wykona przeliczenia kolekcji ściągniętej z serwera, tylko zoptymalizuje zapytanie. To serwer SQL policzy, ile mamy różnych oprawek do okularów i zwróci wartość skalarną.
Napisałem „powinno”, bo to nie my mamy kontrolę nad implementacją interfejsu, a dostawca Data Providera. My możemy tylko liczyć, że kontrakt (interfejs) będzie wykonany. Spokojnie, jeśli to będzie Entity Framework, to nie mamy czego się obawiać ;). Z wywodu można wysnuć wniosek (prawidłowy), że nie ma jednego Linq. Linq zależy od źródła danych, na którym operuje wyrażenie.
To jest klasa PagedList

using System.Collections.Generic;
using System.Linq;

public class PagedList<T> : List<T>, IPagination where T: class
{
    public int CurrentPage { get; }
    public int TotalPages { get; }
    public int PageSize { get; }
    public int TotalCount { get; }
    public bool HasPrevious => CurrentPage > 1;
    public bool HasNext => CurrentPage < TotalPages;

    private PagedList(IEnumerable<T> items, int count, int pageNumber, int pageSize)
    {
        TotalCount = count;
        PageSize = pageSize;
        CurrentPage = pageNumber;
        TotalPages = (int)Math.Ceiling(count / (double)pageSize);
        AddRange(items);
    }
    
    public static PagedList<T> Create(IQueryable<T> source, int pageNumber, int pageSize)
    {
        var count = source.Count();
        var items = source
            .Skip((pageNumber - 1) * pageSize)
            .Take(pageSize)
            .ToList();
        return new PagedList<T>(items, count, pageNumber, pageSize);
    }
}

Było już o tym, że obiekt PagedList, sam policzy sobie wartości potrzebne do generowania stronicowania (CurrentPage, HasPrevious itd). Wrócimy do tego, skąd bierze potrzebne do tego informacje? Na razie wiemy, że przechowuje swój stan. Skoro tak, to możemy użyć go do przygotowania customowego Tag helpera. Robi się to w obiekcie dziedziczącym po abstrakcyjnej klasie TagHelper.
W aplikacjach MVC dużą rolę odgrywają konwencje. Dotyczy to także mapowania atrybutów tagu z kodzie html na właściwości obiektu tag helpera. Nie ma silnego typowania, różne języki, trzeba sobie jakoś radzić… Wywołanie tag helpera będzie wyglądało tak:

<paging pagination="@Model" first-page-text="Pierwsza" last-page-text="Ostatnia" controller="FramesController" action="GetFrames"></paging>

Obiekt tag helpera (z grubsza, bo wyciąłem to co w tym momencie zbędne) wygląda tak, jak niżej. Łatwo się zorientować, że nazwa tagu to pierwszy człon nazwy obiektu PagingTagHelper, a atrybuty są mapowane na właściwości. W ten sposób kiedy proces renderujący stronę natrafi na tag <paging>, to utworzy instancję PagingTagHelper i zainicjuje właściwości wartościami atrybutów tagu.

public class PagingTagHelper : TagHelper
{
    public IPagination Pagination { get; set; }
    public string FirstPageText { get; set; } = "First";
    public string LastPageText { get; set; } = "Last";
    public string Controller { get; set; }
    public string Action { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        BuildMainTag(output);
        BuildFirstLastPageButton(FirstPageText, 1, Pagination.CurrentPage == 1, output);
        BuildFirstLastPageButton(@"«",  Pagination.CurrentPage - 1, Pagination.CurrentPage == 1, output);
        BuildPageButtons(Pagination.CurrentPage, Pagination.TotalPages, output);
        BuildFirstLastPageButton(@"»",  Pagination.CurrentPage + 1, Pagination.CurrentPage == Pagination.TotalPages, output);
        BuildFirstLastPageButton(LastPageText, Pagination.TotalPages, Pagination.CurrentPage == Pagination.TotalPages, output);
    }
}

Potem uruchamiana jest metoda Process(), ktora w kolejnych krokach buduje obiekt TagHelperOutput. Jeden z tych kroków pokazuje listing poniżej. Obiekt TagHelperOutput zawiera właściwość TagHelperContent:Content, która z kolei ma metodę rozszerzającą AppendHtml(). Budowanie htmla polega na wywoływaniu metody AppendHtml() przyjmującej enkodowany html. Metodę AppendHtml() wywołuje się wielokrotnie 'doklejając’ kawałki htmla. Na zakończenie metoda Process() zwraca wynik do silnika Razor. W ten sposób kolejno budowane są klawisze nawigacji.

private void BuildPageButtons(int pageNumber, int totalPages,  TagHelperOutput output)
{
    if (totalPages < 8)
    {
        for (var i = 1; i <= totalPages; i++)
        {
            BuildPageButton(pageNumber, i, output);
        }
    }
    else
    {
        BuildSpacerButton(Pagination.HasPrevious, output);
        for (var i = Math.Max(1, pageNumber - 2); i <= Math.Min(totalPages, pageNumber + 2); i++)
        {
            BuildPageButton(pageNumber, i, output);
        }
        BuildSpacerButton(Pagination.HasNext, output);
    }
}

Powyżej jest kod jednej z prywatnych metod naszego tag helpera, pokazująca jak budowany jest kod html. Metoda generuje przyciski z numerami stron 1, 2 do 7 Jeśli stron jest więcej niż 7, to widać tylko 7 numerowanych klawiszy i symbol …
Efekt wygląda tak:

Dla pierwszej strony klawisze nawigacji po lewej stronie są nie aktywne
Jeśli jest więcej niż 7 stron, to generowany jest symbol …

Klasa PaginationTagHelper wyposażona jest oczywiście w konstruktor i przeznaczona do pracy z kontenerem Dependency Injection. LinkGenerator jest dostępny automatycznie i udostępniany w przestrzeni nazw Microsoft.AspNetCore.Routing. Natomiast domyślną implementację IHttpContextAccessor należy zarejestrować services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
Tag helper trzeba zaimportować do widoków dyrektywą @addTagHelper (najczęściej robi się to w pliku '_ViewImports.cshtml’).
Cały kod jest poniżej:

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.AspNetCore.Routing;

public class PaginationTagHelper : TagHelper
{
    private IPagination Pagination { get; set; }
    public string FirstPageText { get; set; } = "First";
    public string LastPageText { get; set; } = "Last";
    public string Controller { get; set; }
    public string Action { get; set; }

    private readonly LinkGenerator _linkGenerator;
    private readonly IHttpContextAccessor _contextAccessor;
    public PaginationTagHelper(LinkGenerator linkGenerator, IHttpContextAccessor contextAccessor)
    {
        _linkGenerator = linkGenerator;
        _contextAccessor = contextAccessor;
    }
    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        BuildMainTag(output);
        BuildFirstLastPageButton(FirstPageText, 1, Pagination.CurrentPage == 1, output);
        BuildFirstLastPageButton(@"«",  Pagination.CurrentPage - 1, Pagination.CurrentPage == 1, output);
        BuildPageButtons(Pagination.CurrentPage, Pagination.TotalPages, output);
        BuildFirstLastPageButton(@"»",  Pagination.CurrentPage + 1, Pagination.CurrentPage == Pagination.TotalPages, output);
        BuildFirstLastPageButton(LastPageText, Pagination.TotalPages, Pagination.CurrentPage == Pagination.TotalPages, output);
    }

    private static void BuildMainTag(TagHelperOutput output)
    {
        output.TagName = "ul";
        output.Attributes.Add("class", "pagination");
        output.Attributes.Add("aria-label", "Stronicowanie");
    }

    private void BuildFirstLastPageButton(string pageText, int pageNumber, bool disabled, TagHelperOutput output)
    {
        var li = new TagBuilder("li");
        li.Attributes.Add("class", disabled ? "page-item disabled" : "page-item");
        li.Attributes.Add("aria-label", pageText);
        li.TagRenderMode = TagRenderMode.StartTag;
        output.Content.AppendHtml(li);

        var path = _linkGenerator.GetPathByAction(_contextAccessor.HttpContext, Action, Controller, new {pageNumber, pageSize = Pagination.PageSize});
        var link = $@"<a class=""page-link"" href=""{path}"">{pageText}</a>";
        output.Content.AppendHtml(link);
        output.Content.AppendHtml("</li>");
    }

    private void BuildPageButtons(int pageNumber, int totalPages,  TagHelperOutput output)
    {
        if (totalPages < 8)
        {
            for (var i = 1; i <= totalPages; i++)
            {
                BuildPageButton(pageNumber, i, output);
            }
        }
        else
        {
            BuildSpacerButton(Pagination.HasPrevious, output);
            for (var i = Math.Max(1, pageNumber - 2); i <= Math.Min(totalPages, pageNumber + 2); i++)
            {
                BuildPageButton(pageNumber, i, output);
            }
            BuildSpacerButton(Pagination.HasNext, output);
        }
    }

    private void BuildPageButton(int pageNumber, int buttonNumber, TagHelperOutput output)
    {
        var li = new TagBuilder("li");
        li.Attributes.Add("class", buttonNumber == pageNumber ? "page-item active" : "page-item");
        li.Attributes.Add("aria-label", buttonNumber.ToString());
        li.TagRenderMode = TagRenderMode.StartTag;
        output.Content.AppendHtml(li);
        
        var path = _linkGenerator.GetPathByAction(_contextAccessor.HttpContext, Action, Controller, new {pageNumber = buttonNumber, pageSize = Pagination.PageSize});
        var link = $@"<a class=""page-link"" href=""{path}"">{buttonNumber}</a>";
        output.Content.AppendHtml(link);
        output.Content.AppendHtml("</li>");
    }

    private static void BuildSpacerButton(bool isVisible, TagHelperOutput output)
    {
        const string span = @"<span class=""page-link"">...</span>";

        if (!isVisible) return;
        
        var li = new TagBuilder("li");
        li.Attributes.Add("class", "page-item disabled");
        li.Attributes.Add("aria-hidden", bool.TrueString);
        li.TagRenderMode = TagRenderMode.StartTag;
        output.Content.AppendHtml(li);
        output.Content.AppendHtml(span);
        output.Content.AppendHtml("</li>");
    }
}

To już prawie koniec, ale cały czas nie mamy danych, które będą stronicowane. To niedopatrzenie rozwiązuje się w warstwie aplikacji odpowiedzialnej za persystencję danych. Tam budujemy PagedList w oparciu o dane z kontrolera (pageSize, pageNumber) i dane z bazy.

public PagedList<GlassesFrame> GetFrames(int pageSize, int pageNumber)
{
    var frames= _productManager.GlassesFrames
            .Include(x => x.GlassesFramesSizes)
            .ThenInclude(x => x.Sizes);

    return PagedList<GlassesFrame>.Create(frames, pageNumber, pageSize);
}

Taki tag helper jest w pełni funkcjonalny. Wrożenie jest błyskawiczne. Potrzebny jest plik z kodem interfejsu i klasy PagedList oraz plik z klasą PaginationTagHelper oraz rejestracja jednego serwisu i zaimportowanie tag heplera. Wykorzystanie w kodzie to jedna linijka przytoczona już wyżej w tekście.

Miłego kodzenia 🙂


Repozytorium z przykładową aplikacją wykorzystującą PaginationTagHelper: github

Dodaj komentarz