Developing Your First App with HTMX and .NET: Part IV

Developing Your First App with HTMX and .NET: Part IV

Continuing with our application development, we will now implement pagination for our product list page, as shown in the following image:

You can download the starting code from the previous article. Open the solution and add a ListResponse.cs file with the following content:

namespace MyApp;

public class ListResponse<T>
{
    public int Page { get; set; }
    public int PageSize { get; set; }
    public int TotalCount { get; set; }
    public IEnumerable<T> Items { get; set; }
    public ListResponse(IEnumerable<T> items, int page, int pageSize, int totalCount)
    {
        Page = page;
        PageSize = pageSize;
        TotalCount = totalCount;
        Items = items;
    }
}

The class above wraps our query results with their pagination information. Add the Extension.cs file as follows:

using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Primitives;
namespace MyApp;

public static class Extensions
{
    public static async Task<ListResponse<T>> ToPagedListAsync<T>(
    this IQueryable<T> source,
    int page,
    int pageSize)
    {
        var totalCount = await source.CountAsync();
        if (totalCount > 0)
        {
            var items = await source
                .Skip((page - 1) * pageSize)
                .Take(pageSize)
                .ToListAsync();
            return new ListResponse<T>(items, page, pageSize, totalCount);
        }
        return new(Enumerable.Empty<T>(), 0, 0, 0);
    }

    public static Uri AddQuery(this string path, IEnumerable<KeyValuePair<string, StringValues>> query)
    {
        var queryBuilder = new QueryBuilder(query);
        var uriBuilder = new UriBuilder()
        {
            Path = path,
            Query = queryBuilder.ToString()
        };
        return uriBuilder.Uri;
    }
}

The ToPagedListAsync extension method is used to retrieve and wrap the paged data. The AddQuery method helps us build a URI based on a path and the query parameters. Update the Products/ListProducts.cs file as follows:

using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Primitives;
namespace MyApp.Products;
public static class ListProducts
{
    public class Request
    {
        public string? Name { get; set; }
    }

    public static async Task<RazorComponentResult> HandlePage(
        [FromServices] MyAppDbContext appDbContext,
        [AsParameters] Request request,
        HttpContext httpContext,
        [FromQuery] int page = 1,
        [FromQuery] int pageSize = 10)
    {
        var name = request.Name ?? string.Empty;
        var results = await appDbContext.Set<Product>()
            .AsNoTracking().Where(p => p.Name.Contains(name)).ToPagedListAsync(page, pageSize);
        var uri = "/products/list".AddQuery(new KeyValuePair<string, StringValues>[]
        {
            new("Name", request.Name),
            new("Page", page.ToString()),
            new("PageSize", pageSize.ToString())
        });
        httpContext.Response.Headers.Append("HX-Push-Url", uri.PathAndQuery);
        return new RazorComponentResult<ListProductsPage>(new { Results = results, Query = uri.Query });
    }
}

We are adding two parameters to the HandlePage method: one to specify the current page and another to select the page size. To retrieve the data from the database, we are using the ToPagedListAsync extension method previously created. The last change involves building the corresponding URI for our endpoint. Add the RazorComponents/Pagination.razor file with the following content:

@using Microsoft.AspNetCore.Http.Extensions
@using Microsoft.Extensions.Primitives
@using System.Web

<nav class="btn-toolbar">
    <ul class="pagination">
        <li class="page-item @(_hasPreviousPage?"":"disabled")">
            <a class="page-link"
               href="#"
               hx-get=@(_hasPreviousPage?GetUri(Page-1, PageSize):string.Empty)
               hx-swap=@HtmxAttributes.Swap
               hx-target=@HtmxAttributes.Target
               hx-select=@HtmxAttributes.Select>Previous</a>

        </li>
        @for (int i = 1; i <= _totalPages; i++)
        {
            <li class="page-item @(Page==i?"active":"")">
                <a class="page-link"
                   href="#"
                   hx-get=@GetUri(i, PageSize)
                   hx-swap=@HtmxAttributes.Swap
                   hx-target=@HtmxAttributes.Target
                   hx-select=@HtmxAttributes.Select>@i</a>
            </li>

        }
        <li class="page-item @(_hasNextPage?"":"disabled")">
            <a class="page-link"
               href="#"
               hx-get=@(_hasNextPage?GetUri(Page+1, PageSize):string.Empty)
               hx-swap=@HtmxAttributes.Swap
               hx-target=@HtmxAttributes.Target
               hx-select=@HtmxAttributes.Select>Next</a>
        </li>
    </ul>
    <div class="dropdown ms-auto">
        <button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown">
            Rows per page (@PageSize)
        </button>
        <ul class="dropdown-menu">
            <li>
                <a class="dropdown-item"
                   hx-get="@GetUri(1, 10)"
                   hx-swap=@HtmxAttributes.Swap
                   hx-target=@HtmxAttributes.Target
                   hx-select=@HtmxAttributes.Select
                   href="#">10</a>
            </li>
            <li>
                <a class="dropdown-item"
                   hx-get="@GetUri(1, 20)"
                   hx-swap=@HtmxAttributes.Swap
                   hx-target=@HtmxAttributes.Target
                   hx-select=@HtmxAttributes.Select
                   href="#">20</a>
            </li>
            <li>
                <a class="dropdown-item"
                   hx-get="@GetUri(1, 40)"
                   hx-swap=@HtmxAttributes.Swap
                   hx-target=@HtmxAttributes.Target
                   hx-select=@HtmxAttributes.Select
                   href="#">40</a>
            </li>
        </ul>
    </div>
</nav>
@code {
    [Parameter, EditorRequired]
    public int Page { get; set; } = 1;
    [Parameter, EditorRequired]
    public int PageSize { get; set; } = 10;
    [Parameter, EditorRequired]
    public int TotalCount { get; set; } = default!;
    [Parameter, EditorRequired]
    public HtmxAttributes HtmxAttributes { get; set; } = default!;
    [Parameter, EditorRequired]
    public string Query { get; set; } = default!;
    private int _totalPages;
    private bool _hasPreviousPage;
    private bool _hasNextPage;

    protected override void OnParametersSet()
    {
        _totalPages = (int)Math.Ceiling(TotalCount / (double)PageSize);
        _hasPreviousPage = Page > 1;
        _hasNextPage = Page < _totalPages;
    }

    public string? GetUri(int page, int pageSize)
    {
        var query = HttpUtility.ParseQueryString(Query);
        query["Page"] = page.ToString();
        query["PageSize"] = pageSize.ToString();
        var uriBuilder = new UriBuilder()
            {
                Path = HtmxAttributes.Endpoint,
                Query = query.ToString()
            };
        return uriBuilder.Uri.PathAndQuery;
    }
}

We use two Bootstrap components to build this Razor component: pagination and dropdown. The component's inputs are the current page, the page size, the total number of records, the HTMX attributes, and the query parameters. The OnParametersSet method calculates the total number of pages and whether to enable the previous and next page buttons. The method GetUri is used to build the correct URLs. Run the application to see the new Razor component in action:

You can find the final code here. Thank you, and happy coding.