DEV Community

Shaun Curtis
Shaun Curtis

Posted on

Controlling Routing and Navigation in Blazor Edit Forms

CEC.Routing

Single Page Applications have several issues running as applications in a web browser. One such is navigation: the user can navigate away from the page in a variety of ways where the application has little control over what happens. Data loss often occurs in a less than satisfactory experience for the user.

This library seeks to address the following navigation issues:

  1. Intercepting and potentially cancelling intra-application routing when the page is dirty.

  2. Intercepting and warning on a navigation event away from the application - there's no browser mechanism to prevent this. Entering a new URL in the URL bar, clicking on a favourite, ...

Library and Example Repositories

CEC.Routing is an implementation of the standard Blazor router with functionality needed to control intra-application routing and the onbeforeunload browser event behaviour. It's released and available as a Nuget Package. The source code is available at https://github.com/ShaunCurtis/CEC.Routing.

All the source code is available under the MIT license.

Intra-Application Routing

Intercept routing on a dirty i.e. unsaved data page isn't possible with the out-of-the-box Blazor navigator/router. In fact we have no way to get to the internal routing decision making, so need to start from scratch.

A quick digression on the basics of Blazor navigation/routing.

DOM navigation events such as anchors, etc are captured by the Blazor JavaScript Interop code. They surface in the C# Blazor world through the NavigationManager. The user clicks on an HTML link or navlink in the browser, the NavigationManager service instance gets populated with the relevant URL data and the NavigationManager.LocationChanged event is fired. That's it for the NavigationManager. The heavy lifting is done by the Router. It gets initialized through app.razor on a page load, and wires itself into the NavigationManager.LocationChanged event. The developer has no access to its internal workings, so can't cancel anything.

Fortunately, we can clone the standard router and add the necessary functionality. The new router is called RecordRouter. The key changes to the out-of-the-box router are as follows:

RouterSessionService

Create a new scoped Service called RouterSessionService for controlling and interacting with the RecordRouter.

 public class RouterSessionService
{
/// <summary>
/// Property containing the currently loaded component if set
/// </summary>
public IRecordRoutingComponent ActiveComponent { get; set; }

 /// <summary>
/// Boolean to check if the Router Should Navigate
 /// </summary>

public bool IsGoodToNavigate => this.ActiveComponent?.IsClean ?? true;

/// <summary>
/// Url of Current Page being navigated from
/// </summary>
public string PageUrl => this.ActiveComponent?.PageUrl ?? string.Empty;

 /// <summary>
 /// Url of the previous page
 /// </summary>
public string LastPageUrl { get; set; }

/// <summary>
/// Url of the navigation cancelled page
 /// </summary>

public string NavigationCancelledUrl { get; set; }

/// <summary>
/// Event to notify Navigation Cancellation
/// </summary>

public event EventHandler NavigationCancelled;

/// <summary>
/// Event to notify that Intra Page Navigation has taken place
/// useful when using Querystring controlled pages
 /// </summary>
public event EventHandler IntraPageNavigation;

private readonly IJSRuntime _js;

private bool _ExitShowState { get; set; }

public RouterSessionService(IJSRuntime js) => _js = js;

/// <summary>
/// Method to trigger the NavigationCancelled Event
/// </summary>
public void TriggerNavigationCancelledEvent() => this.NavigationCancelled?.Invoke(this, EventArgs.Empty);

/// <summary>
/// Method to trigger the IntraPageNavigation Event
/// </summary>
public void TriggerIntraPageNavigation() => this.IntraPageNavigation?.Invoke(this, EventArgs.Empty);

/// <summary>
/// Method to set or unset the browser onbeforeexit challenge
/// </summary>
/// <param name="action"></param>
/// <returns></returns>
public void SetPageExitCheck(bool show)
 {
   if (show != _ExitShowState) _js.InvokeAsync<bool>("cec_setEditorExitCheck", show);
    _ExitShowState = show;
 }
}

RecordRouter

This is a straight clone of the shipped router. The only changes are in the OnLocationChanged event handler. It now looks like this:

private void OnLocationChanged(object sender, LocationChangedEventArgs args)
{

  // Get the Page Uri minus any query string
  var pageurl = this.NavigationManager.Uri.Contains("?") ? this.NavigationManager.Uri.Substring(0, this.NavigationManager.Uri.IndexOf("?")): this.NavigationManager.Uri ;
  _locationAbsolute = args.Location;

  // CEC ADDED - SessionState Check for Unsaved Page
  if (_renderHandle.IsInitialized && Routes != null && this.RouterSessionService.IsGoodToNavigate)
  {
    // Clear the Active Component - let the next page load itself into it if required
    this.RouterSessionService.ActiveComponent = null;
    this.RouterSessionService.NavigationCancelledUrl = null;
    Refresh(args.IsNavigationIntercepted);
  }
  else
  {
    // CEC ADDED - Trigger a Navigation Cancelled Event on the SessionStateService
    if (this.RouterSessionService.PageUrl.Equals(_locationAbsolute, StringComparison.CurrentCultureIgnoreCase))
      {
        // Cancel routing
        this.RouterSessionService.TriggerNavigationCancelledEvent();
      }
    else
    {
      // we're cancelling routing, but the Navigation Manager is current set to the aborted page
      // so we set the navigation cancelled url so the page can navigate to it if necessary
      // and do a dummy trip through the Navigation Manager again to set this back to the original page
      this.RouterSessionService.NavigationCancelledUrl = this.NavigationManager.Uri;
      this.NavigationManager.NavigateTo(this.RouterSessionService.PageUrl);
    }
  }
  if (RouterSessionService.LastPageUrl != null && RouterSessionService.LastPageUrl.Equals(pageurl, StringComparison.CurrentCultureIgnoreCase)) RouterSessionService.TriggerIntraPageNavigation();
  RouterSessionService.LastPageUrl = pageurl;
}

On a NavigationManager.LocationChanged event, the method:

  1. Gets the page URL minus any query string, and checks if we are good to route. (I like to use a combination of routing and query strings - more flexible than all routing in many instances).

    Yes - clears out the relevant fields on the RouterSessionService and routes through the Refresh method.

    No - triggers a NavigationCancelled event. We solve the displayed URL issue by making a second dummy run through navigation to reset the displayed URL. Anyone know a better way?

  2. Check for Intra-Page Navigation and trigger the IntraPageNavigation event if needed. Useful where only the query string changes.

The routing is controlled by the IsGoodToNavigate property on the RouterSessionService.

public bool IsGoodToNavigate => this.ActiveComponent?.IsClean ?? true;

This is only false when we cancel routing - i.e. the ActiveComponent exists and is dirty. A lot of coding/refactoring to make a binary check!

Setting up you site to use the Router

Install the Nuget Package

Startup.cs

Add the CEC.Routing services

using CEC.Routing;
 
public void ConfigureServices(IServiceCollection services)
{
....
services.AddCECRouting();
....
}

_Imports.razor

Add the following namespace references

@using CEC.Routing
@using CEC.Routing.Services
@using CEC.Routing.Router

App.razor

Change the name of the Router to RecordRouter

<RecordRouter AppAssembly="@typeof(Program).Assembly">
......
</RecordRouter>

Implementing the Router

There's a sample site on the Github repository demonstrating the use of the library on a WeatherForecast editor.

NOTE - Record routing only kicks in if you set up a page component to use it. Normal pages will route as normal: you don't need to configure them not to.

You interact with the router through the RouterSessionService. To configure a page to use the extra routing functionality:

  1. Inject the service into any edit page.

  2. Implement the IRecordRoutingComponent Interface on the page

Next you need to add an event handler for the navigation cancelled event. This should contain code to tell the user that navigation was cancelled and potentially ask them if they really want to leave the page.

protected virtual void OnNavigationCancelled(object sender, EventArgs e)
{
  this.NavigationCancelled = true;
  this.ShowExitConfirmation = true;
  this.AlertMessage.SetAlert("<b>THIS RECORD ISN'T SAVED</b>. Either <i>Save</i> or <i>Exit Without Saving</i>.", Alert.AlertDanger);
  InvokeAsync(this.StateHasChanged);
}

This one (from the EditRecordComponentBase boilerplate in the project):

  1. Sets a couple of local properties used in controlling which buttons display when.

  2. Sets an alert box to display.

  3. Calls StateHasChanged to refresh the UI.

Add the following code to the component OnInitialized or OnInitializedAsync event

  this.PageUrl = this.NavManager.Uri;
  this.RouterSessionService.ActiveComponent = this;
  this.RouterSessionService.NavigationCancelled += this.OnNavigationCancelled;

This:

  1. Sets the PageURL property to the current URL (pages names/directories and routing URLs are now very different).

  2. Sets the RouterSessionService ActiveComponent reference to the component.

  3. Attaches the above event hander to the RouterSessionService NavigationCancelled Event.

The final bit of the jigsaw is connecting the IRecordRoutingComponent.IsClean property. Its important to get this right. The router uses this property to route/cancel routing.

In my projects it's wired directly to the IsClean property on the specific data service associated with the record. It gets set when the record in the service changes.

In the CEC.Routing sample project it's set and unset in the CheckForChanges method which is called whenever an edit control is changed.

The following code shows how to override the cancel routing event - such as when the users wants to exit regardless.

        protected void ConfirmExit()
        {
            this.IsClean = true;
            if (!string.IsNullOrEmpty(this.RouterSessionService.NavigationCancelledUrl)) this.NavManager.NavigateTo(this.RouterSessionService.NavigationCancelledUrl);
            else if (!string.IsNullOrEmpty(this.RouterSessionService.LastPageUrl)) this.NavManager.NavigateTo(this.RouterSessionService.LastPageUrl);
            else this.NavManager.NavigateTo("/");
        }

Note that the RouterSessionService holds the cancelled URL.

Intercepting/Warning on external Navigation

You can't lock down the browser window to stop this - I wish we could. The only control browsers offer is the onbeforeunload event. When a function is registered on this event, the browser displays a popup warning dialog, giving the user the option to cancel navigation. The degree of control, what appears in the box, and what you need the attached function to do differs across browsers.

The sledgehammer approach is to add the following to your _Host.html file:

<script>
  window.onbeforeunload = function () {
    return "Do you really want to leave?";
  };
</script>

It forces a popup exit box whenever the users tries to leave the application, like using a wrecking ball to crack a nut. There are many instances where you want to leave the application - authentication, print pages to name a couple. Having the exit popup box coming up every time is a pain.

CEC.Routing implements a more nuanced and focused alternative. It still uses the onbeforeunload event, but dynamically registers and unregisters with the event as needed. i.e. only when a form is dirty.

CEC.Routing.js

The client side Javascript files looks like this (pretty self explanatory):

window.cec_setEditorExitCheck = function (show) {
  if (show) {
    window.addEventListener("beforeunload", cec_showExitDialog);
  }
  else {
    window.removeEventListener("beforeunload", cec_showExitDialog);
  }
}

window.cec_showExitDialog = function (event) {
  event.preventDefault();
  event.returnValue = "There are unsaved changes on this page. Do you want to leave?";
}

The JSInterop code is implemented as a method in RouteSessionService.

private bool _ExitShowState { get; set; }
public void SetPageExitCheck(bool show)
  {
    if (show != _ExitShowState) _js.InvokeAsync<bool>("cec_setEditorExitCheck", show);
   _ExitShowState = show;
  }

Add the following script reference to the _Host.html next to the blazor.server.js reference.

<script src="_content/CEC.Routing/cec.routing.js"></script>

In the CEC.Routing sample WeatherForcastEditor:

protected void CheckClean(bool setclean = false)
{
    if (setclean) this.IsClean = true;
    if (this.IsClean)
    {
      this.Alert.ClearAlert();
      this.RouterSessionService.SetPageExitCheck(false);
    }
    else
    {
      this.Alert.SetAlert("Forecast Changed", Alert.AlertWarning);
      this.RouterSessionService.SetPageExitCheck(true);
    }
}

This method is called by the OnFieldChanged event handler, and the Save and ConfirmExit methods.

Top comments (0)