Search code examples
c#formsasp.net-corerazor

ASP.NET paging is reset with multiple forms


My web interface has a control panel built in Razor Pages, showing varius devices admins can configure, displayed in a table with varius buttons on the side for quick actions and info (like a toggle to show enabled and connection status).

The way i built it initially it had no paging, and a simple loop with a forms column for quick actions, based on the relative row

@{
    ViewBag.BaseLayout = "/Views/Shared/_Layout_Areas.cshtml";
    Layout = "/Views/Shared/_Layout_IPageable.cshtml";
}
@* other code bla bla bla *@
    @foreach (var device in @Model.Devices)
    {
        <tr>
            <td class="forms-inline">
                <form id="entrydelete" method="post" onsubmit="return _formconfirm();">
                    <button class="btn btn-danger btn-sm" type="submit" asp-page-handler="delete" asp-route-id="@device.Id">
                        <i class="bi bi-trash3"></i>
                    </button>
                </form>
                <form id="useredit" method="post">
                    <button class="btn btn-warning btn-sm d-inline" type="submit" asp-page-handler="update" asp-route-id="@device.Id">
                        <i class="bi bi-pencil-square"></i>
                    </button>
                </form>
                <form id="toggle" method="post" target="_self">
                    <button class="btn @(device.Enabled ? "btn-success" : "btn-secondary" )  btn-sm d-inline" type="submit" asp-page-handler="toggleEnabled" asp-route-id="@device.Id">
                        @if (device.Enabled)
                        {
                            <i class="bi bi-toggle-on"></i>
                        }
                        else
                        {
                            <i class="bi bi-toggle-off"></i>
                        }
                    </button>
                </form>
            </td>
            <td>@Html.Label("Description",device.Description ?? "---")</td>
            <td class="font-monospace w-space-pre">@Html.Label("Ip",device.Ip)</td>
            <td class="font-monospace">@Html.Label("Port",device.Port.ToString())</td>
            <td>@Html.Label("Organization",device.OwnedBy?.Name)</td>
            <!-- etc. etc. -->
        </tr>
        }

Now this works great if you have no paging and stuff... But later on as devices grew exponentially a paging solution needed implementation.

So i added an IPageable interface and implemented extension methods and stuff, but i added an additional layout Layout_IPageable.cshtml adding the paging controls at the bottom of the page in the classic [<-] [1] ... [n-1] [n] [n+1] ... [nmax] [->] for reusability and style consistency across multiple pages, but i made it a form for general filtering for specific pages _Layout_IPageable.cshtml

@model IPageable
@{
    Layout = ViewBag.BaseLayout ?? "/Views/Shared/_Layout.cshtml";
}
<!-- render body and styles and stuff -->
<div class="paging form text-end">
    <label asp-for="Paging.PageNumber" class="form-label"></label>
    @if (Model.Paging.PageNumber > 1)
    {
        <button class="btn btn-sm btn-secondary" onclick="navigatePaging(-1)"><i class="bi bi-arrow-left"></i></button>
    }
    else
    {
        <button class="btn btn-sm btn-outline-secondary" disabled><i class="bi bi-arrow-left"></i></button>
    }
    <!-- etc. etc. -->
    [ <input asp-for="Paging.PageNumber" style="width: min-content" form="search" min="1" max="@Model.Paging.MaxPageCount" onchange="this.form.submit()" />
        /@Html.Label(Model.Paging.MaxPageCount.ToString()) ]
    <!-- etc. etc. -->
    <label asp-for="Paging.Take" class="form-label"></label>
    <select asp-for="Paging.Take" form="search" onchange="this.form.submit()">
        <option>10</option>
        <option>25</option>
        <option>40</option>
        <option>100</option>
    </select>
</div>
interface IPageable
{
    DataPagingModel Paging { get; set; }
}
public static class PagingExtensions
{
    public static IEnumerable<T> Page<T>( this IEnumerable<T> input, DataPagingModel paging )
    {
        paging.ResultsCount = input.Count();
        paging.MaxPageCount = (input.Count()-1) / paging.Take + 1;
        paging.PageNumber = Math.Min(paging.PageNumber, paging.MaxPageCount);
        return input
            .Skip(paging.Skip)
            .Take(paging.Take);
    }
}
[ValidateAntiForgeryToken]
public partial class DevicesModel : PageModel, IPageable
{
    [BindProperty(SupportsGet = true)]
    public DataPagingModel Paging { get; set; }

    public IActionResult OnGet()
    {
        thid.Devices = this._devicesSrvc
            .VariusChainedLinqMethods()
            .Page(this.Paging);
        return this.Page();
    }

    public async Task<IActionResult> OnPostUpdate( Guid id )
    {
        /* code to update and stuff ... */
        
        // the problem is here, in non-get methods i cannot figure out 
        //  how to return the correct paging because this.Paging is null!
        return this.OnGet();
    }

    /* etc. etc. */

}

The problem is that now when previus "quick actions" forms are submitted, obviusly the paging form is NOT submitted as well, and the paging resets to the default (so page 0, with default page size and no filters).

How should i go about this?

Implement the previus "quick actions" forms as API calls and then reload the page? Or is there a more elegant solution?


Solution

  • The solutions was pretty simple actually, because the original query is sent by the browser as an URL in the Referer HTTP Header when submitting any form.

    So when sending one of the "post" forms, i expect the referer to be the original GET query for the page.

    Given it's not something to completely rely on, but returning this at the end of the varius Post handlers...

    protected IActionResult RedirectReferOrReload( )
    {
        var referer = this.Request.Headers.Referer.SingleOrDefault();
        if (string.IsNullOrEmpty(referer))
            return this.RedirectToPage();
        // this ensures the referer cannot go to a malicius link
        return this.Redirect(this.Request.Path.Value + new Uri(referer).Query);
    }
    
    public async Task<IActionResult> OnPostUpdate( Guid id )
    {
        /* code to update and stuff ... */
        return this.RedirectReferOrReload();
    }
    

    ... will redirect to the referer page if the header is present, otherwise just reload the page. When the page is then reloaded with the correct query the status of the shown items is correctly updated.

    Now, this works great for my case because i expect default browser configurations from the target users of this page, so that the Referer header is always sent on forms (i also updated ASP.NET to specify a Referrer-Policy).