How to Sort Data in ASP.NET Razor Pages

In previous posts we've look at filtering and  paging data in ASP.NET Razor Pages. Along with paging data almost always comes the requirement to sort the data as well. Especially when displayed in a table like in our example project.

Sorting Requirements

For sorting in our example project we'll take on the following requirements:

  • Default sort by the first column
  • Sort by clicking on the column header
  • Sorting by the currently sorted column will reverse the sort order
  • Sorting will reset paging to page 1
  • Sorting the data will be done at the database layer

Add Order to the EF Core Query

Looking at the current query we see that it is currently sorted by the widget name every time we make a call.

Widgets = await _dbContext.Widgets
    .Filter(Filter)
    .OrderBy(x => x.Name)
    .ToPagedList(PageIndex, AppConfig.PageSize);

To start we'll create a new "SortDirection" enum that we can use to indicate sort direction in our application.

public enum SortDirection
{
    Asc,
    Desc
}

Following that we'll add a new "OrderBy" extension method in the Widget model that accepts a name and sort direction.

public static IQueryable<Widget> Orderby(this IQueryable<Widget> query, string name, SortDirection direction = SortDirection.Asc){

    Expression<Func<Widget, object>> exp = name?.ToLower() switch
    {            
        "part" => x => x.PartNumber,
        "description" => x => x.Description,
        "quantity" => x => x.Quantity,
        "price" => x => x.Price,
        _ => x => x.Name
    };

    return direction == SortDirection.Asc ? query.OrderBy(exp)
                            : query.OrderByDescending(exp);
}

The switch statement used to build the expression allows us map known search strings to model properties while also providing some indirection between what we use as a sort key and the underlying database column (we'll take advantage of that later to sort by multiple columns).

With our new "OrderBy" method in place we can easily update the get method to return widgets by part number.

public async Task<IActionResult> OnGetAsync()
{
    Widgets = await _dbContext.Widgets
                .Filter(Filter)
                .OrderBy("part")
                .ToPagedList(PageIndex, AppConfig.PageSize);

    return Page();
}

Update the Page Model

The next step is to updated our page model to allow the sort value and direction to be supplied to the get method.  The PageIndex and Filter properties are passed in as part of the query string so we'll add sort and direction properties to the query string as well by adding the properties to "Widgets.cshtml.cs".

[BindProperty(SupportsGet = true)]
public string Sort { get; set; }

[BindProperty(SupportsGet = true)]
public SortDirection Direction { get; set; }

Then we can updated the get method to pass the values of those properties to the "OrderBy" extension.

public async Task<IActionResult> OnGetAsync()
{
    Widgets = await _dbContext.Widgets
                .Filter(Filter)
                .OrderBy(Sort, Direction)
                .ToPagedList(PageIndex, AppConfig.PageSize);

                return Page();
}

If we add the "sort" and "direction" values to the query string manually we can see the values sorted.

Before leaving the page model we need to make one more update to the "LinkData"  property in order to pass along the sort values to parts of the view that may need it.

public Dictionary<string, string> LinkData =>
new()
{
    {"filter", Filter},
    {"p", Widgets.CurrentPage.ToString()},
    {"sort", Sort},
    {"direction", Direction.ToString()}
};

If we manually add a sort value to the query string now click through the paging control will honor the added sort value and direction.

Add Sorting to Table Headers

Our last step to get sorting working is to update the table headers to create the correct sort link.

Initially it looks like updating the table column to create a link should be all we need.

<th>
    <a asp-route-filter="@Model.Filter" asp-route-sort="name" asp-route-direction="@Model.Direction">
    	Name
    </a>
</th>

This simple link misses the scenario where we have to flip the sort direction if the table header is currently being sorted. To help us determine which direction to order by when a header is clicked we will create a simple helper method in the page model. The method will take the sort column and default sort direction and return the next sort direction.

public SortDirection GetNextSortDirection(string name, SortDirection defaultOrder){
    if(Sort?.ToLower() != name?.ToLower()){
    	return defaultOrder;
    }

    return Direction == SortDirection.Asc? SortDirection.Desc: SortDirection.Asc;
}

Then we'll call that method from our view for each column header to set the "asp-route-direction" parameter.

<thead>
         <tr>
             <th>
               <a asp-route-filter="@Model.Filter" asp-route-sort="name" asp-route-direction="@Model.GetNextSortDirection("name", SortDirection.Asc)">
                  Name
               </a>
             </th>
             <th>
                <a asp-route-filter="@Model.Filter" asp-route-sort="part" asp-route-direction="@Model.GetNextSortDirection("part", SortDirection.Desc)">
                Part Number
                </a>
            </th>
             <th>
                <a asp-route-filter="@Model.Filter" asp-route-sort="description" asp-route-direction="@Model.GetNextSortDirection("description", SortDirection.Desc)">
                Description
                </a>
            </th>
             <th>
                <a asp-route-filter="@Model.Filter" asp-route-sort="quantity" asp-route-direction="@Model.GetNextSortDirection("quantity", SortDirection.Desc)">
                Quantity
                </a>
            </th>
             <th>
                <a asp-route-filter="@Model.Filter" asp-route-sort="price" asp-route-direction="@Model.GetNextSortDirection("price", SortDirection.Desc)">
                  Price
                </a>
             </th>
         </tr>
      </thead>

By simply not including a value for the page index in the table header links we are effectively setting the results to start at the first page.

Combining Columns

This sorting works great but what if we had a new requirement to combine two or more values into one column?

To support multiple columns we have to update the view to pass a derived name for the new column and also update the OrderBy extension method to recognize the derived column name.

If we wanted to combine the Quantity and Price columns in the view we would render the header like any other column header, give it a derived name of 'quantityprice' and then display the values together in a single column.

<th>
    <a asp-route-filter="@Model.Filter" asp-route-sort="quantityprice" asp-route-direction="@Model.GetNextSortDirection("quantityprice", SortDirection.Desc)">
    	Quanity - Price
    </a>
</th>

 ....
 
 <tr>
 	...
     <td>
     	@w.Quantity / @w.Price.ToString("C")
     </td>
 </tr>

In the "OrderBy" extension method we can update the expression to know how to order by the new "quantityprice" name.

public static IQueryable<Widget> OrderBy(this IQueryable<Widget> query, string name, SortDirection direction = SortDirection.Asc){

    Expression<Func<Widget, object>> exp = name?.ToLower() switch
    {
        "part" => x => x.PartNumber,
        "description" => x => x.Description,
        "quantity" => x => x.Quantity,
        "price" => x => x.Price,
        "quantityprice" => x => x.Quantity + x.Price,
        _ => x => x.Name
    };

    return direction == SortDirection.Asc ? query.OrderBy(exp) : query.OrderByDescending(exp);
}

Inevitably as an application grows you may have to add more ceremony to this extension function if you need to order by values even further from the underlying schema but for the most part this pattern holds up.  

Conclusion

Adding sorting to our widgets view was a pretty easy addition to the existing paging and filtering functions.

Other Scenarios

  • How would we extend this to sort by multiple user selected columns  in the UI?
  • The table header UI could use some sort indicators so the user can easily know which column is currently being sorted and what direction.

Cover image by Drew Beamer on Unsplash