Preventing Users Navigating Away from an AJAX.Net Page without Saving their Changes

December 11, 2009

On ASP.Net / AJAX recent project I was asked to ensure that users cannot navigate away from a web form if they have made changes that have not yet been submitted.

Requirements

The requirements were:

  • Enable users to submit the form by clicking the “Save” or “Cancel” buttons.
  • When user navigates away using any other method, show a dialog that states that changes will be lost if the user continues. These methods to include:
    • Any other controls on the page
    • Back button
    • Refresh button
    • Typing another address in the address bar
    • Picking another page from history
    • Closing the web browser
    • Anything else
  • Only show the dialog if the form is “dirty” (ie. there are unsaved changes”
  • Work with IE6 (I know, I know).
  • The solution should work client-side, so must be written in JavaScript.

Note that his solution has not been tested with other browsers.

Preventing Users Navigating

When a user attempts to navigate using any of the methods above, the window.onbeforeunload event will fire. We can use this event to pop-up a dialog that warns the user that she has not submitted her changes, and asks her to confirm that she wants to continue.

Checking for Dirt

In order to check for dirtiness, we must compare the original value of all the controls on the page with their value when they are submitted. A number of sources suggest that the browser maintains the default value for controls, and that we can compare the current value with that default value in the onbeforeunload event handler. However, there are several issues with this approach:

  • The default value is generally reset following an AJAX postback. This means that we must preserve the control’s original values of the controls when they first load, so gain nothing from using the browser’s method for tracking value changes.
  • In IE6, the defaultSelected property of HTML OPTION controls is, by default, set to true for all items in a drop-down list. The ASP.Net list control appears to set this value explicitly, I didn’t want to have to do so on the equivalent HTML control. As a result of the default, however, it is often impossible to determine if the user has unselected an item or if they have simply not selected the item. As a result it is best to track changes to the selection of options ourselves, rather than using the browser’s faulty tracking mechanism.

Allowing Certain Controls to Submit without Warning

It is important that some controls are able to submit the form without the using being warned that changes have been made. Typically, these controls include a button that allows the user to save her changes, and possibly one to exit without saving.

In order to implement these, we set a flag that indicates that a dirtiness check is required, then use the client-side click event of the buttons to reset the flag so that the check isn’t performed when these buttons are clicked.

OnBeforeUnload Sometimes Fires Twice

There is a bug in IE6 that means that, under some circumstances, the onbeforeunload event fires twice. We don’t want the confirm dialog to be displayed twice when the user attempts to leave a page, so we need to take measures. It is easy to set a flag the first time the onbeforeunload event fires that indicates that we can ignore it the second time. The slight complication is that the user may click “cancel” to prevent the navigation when the dialog first appears. If that happens, we don’t want to suppress the confirmation dialog for subsequent attempts to navigate away from the page. The solution is to start a timer that will reset the flag if the user remains on the page, thus allowing the dialog to appear when the user attempts to navigate again.

The Code

Here’s the JavaScript that I use:


<script type="text/javascript">
<!--

// Function to maintain original default states for all fields.
//  In order to test for dirtiness, we will be checking if the default
//  value for each control matches its current value. However, this
//  default is not normally preserved across partial postbacks. We need to
//  preserve these values ourselves.
function keepDefaults(form) {

// Get a reference to the form (in ASP.Net there should only be the one).
var form = document.forms[0];

// If no original values are yet preserved...
if (typeof (document.originalValues) == "undefined") {

// Create somewhere to store the values.
document.originalValues = new Array();

}

// For each of the fields on the page...
for (var i = 0; i < form.elements.length; i++) {

// Get a ref to the field.
var field = form.elements[i];

// Depending on the type of the field...
switch (field.type) {

// For simple value elements...
case "text":
case "file":
case "password":
case "textarea":

// If we don't yet know the original value...
if (typeof (document.originalValues[field.id]) == "undefined") {

// Save it for later.
document.originalValues[field.id] = field.value;

}
break;

// For checkable elements...
case "checkbox":
case "radio":

// If we don't yet know the original check state...
if (typeof (document.originalValues[field.id]) == "undefined") {

// Save it for later.
document.originalValues[field.id] = field.checked;

}
break;

// For selection elements...
case "select-multiple":
case "select-one":

// The form is dirty if the selection has changed.

// For each of the options...
var options = field.options;
for (var j = 0; j < options.length; j++) {

var optId = field.id + "_" + j;

// If we don't yet know the original selection state...
if (typeof (document.originalValues[optId]) == "undefined") {

// Save it for later.
document.originalValues[optId] = options[j].selected;

}
}
break;
}

}
}

// Call function to preserve defaults every time the page is loaded (or is
// posted back).
Sys.Application.add_load(keepDefaults);

// Assume that a check for dirtiness is required.
//  If this value is still true, we will check for dirtiness when the page
//  unloads.
var dirtyCheckNeeded = true;

// Function to flag that a check for dirtiness is not required.
//  Called by Save and Cancel buttons to indicate that a dirty check is
//  not actually required.
function ignoreDirty() {
dirtyCheckNeeded = false;
}

// Function to check if the page is dirty.
//  The function compares the default value for the control (the one it
//  was given when the page loaded) with its current value.
function isDirty(form) {

// For each of the fields on the page...
for (var i = 0; i < form.elements.length; i++) {
var field = form.elements[i];

// Depending on the type of the field...
switch (field.type) {

// For simple value elements...
case "text":
case "file":
case "password":
case "textarea":

// The form is dirty if the value has changed.
if (field.value != document.originalValues[field.id]) {
// Uncomment the next line for debugging.
//alert(field.type + ' ' + field.id + ' ' + field.value + ' ' + document.originalValues[field.id]);
return true;
}
break;

// For checkable elements...
case "checkbox":
case "radio":

// The form is dirty if the check has changed.
if (field.checked != document.originalValues[field.id]) {
// Uncomment the next line for debugging.
//alert(field.type + ' ' + field.id + ' ' + field.checked + ' ' + document.originalValues[field.id]);
return true;
}
break;

// For selection elements...
case "select-multiple":
case "select-one":

// The form is dirty if the selection has changed.
var options = field.options;
for (var j = 0; j < options.length; j++) {
var optId = field.id + "_" + j;
if (options[j].selected != document.originalValues[optId]) {

// Uncomment the next line for debugging.
//alert(field.type + ' ' + field.id + ' ' + options[j].text + ' ' + options[j].selected + ' ' + document.originalValues[optId]);
return true;
}
}
break;
}
}

// The form is not dirty.
return false;
}

// Clicking on some controls in (at least) IE6 caused the onbeforeunload
// to fire *twice*. We use this flag to check for this condition.
var onBeforeUnloadFired = false;

// Function to reset the above flag.
function resetOnBeforeUnloadFired() {
onBeforeUnloadFired = false;
}

// Handle the beforeunload event of the page.
//  This will be called when the user navigates away from the page using
//  controls on the page or browser navigation (back, refresh, history,
//  close etc.). It is not called for partial post-backs.
function doBeforeUnload() {

// If this function has not been run before...
if (!onBeforeUnloadFired) {

// Prevent this function from being run twice in succession.
onBeforeUnloadFired = true;

// If the dirty check is required...
if (dirtyCheckNeeded) {

// If the form is dirty...
if (isDirty(document.forms[0])) {

// Ask the user if she is sure she wants to continue.
event.returnValue = "If you continue you will lose any changes that you have made to this record.";

}
}
}

// If the user clicks cancel, allow the onbeforeunload function to run again.
window.setTimeout("resetOnBeforeUnloadFired()", 1000);
}

// Hook the beforeunload event of the page.
//  Call the dirty check when the page unloads.
if (window.body) {
// IE
window.body.onbeforeunload = doBeforeUnload;
}
else
// FX
window.onbeforeunload = doBeforeUnload;

// -->
</script>

The source code for my Save and Cancel buttons looks like this:


<asp:Button ID="btnEdit" runat="server" Text="Save" OnClick="btnEdit_Click" OnClientClick="ignoreDirty();" />
<asp:Button ID="btnCancel" runat="server" Text="Cancel" CssClass="btn btnCancel" OnClick="btnCancel_Click" OnClientClick="ignoreDirty();" CausesValidation="false" />

References

I looked at a range of solutions before developing the one I present here, including:

Advertisement

UltraWebTab: Switch to Tab on Validation Error

June 24, 2009

The Problem


I have an ASP.Net form that contains a set of tabs hosted inside an Infragistics UltraWebTab control. Each tab contains a number of fields that have validation. Sometimes, when the user submits the form, a validation error occurs. When this happens, I want the user to be able to correct the error with a minimum of fuss – I want the tab containing the error to be automatically selected, and the control with the error to be given the focus.

The Solution

The following code causes the first tab that contains an error to be selected when the form submits:

// These scripts are responsible for handling validation error messages in the tabs.
// If there are any errors on the page, they switch to the tab containing the first
// error and ensure that the control with the error is focused.

 function preValidate() {
  // For each of the validators on the page...
  var i;
  for (i = 0; i < Page_Validators.length; i++) {
   // Perform the validation.
   ValidatorValidate(Page_Validators&#91;i&#93;);

   // If invalid data is found...
   if (!Page_Validators&#91;i&#93;.isvalid) {

    // Get the control that failed validation.
    var badControl = document.getElementById(Page_Validators&#91;i&#93;.controltovalidate);

    // Focus it.
    focusInvalid(badControl);

    // Stop processing validation.
    return;

   }
  } // Next validator.

  // The page is valid.

 }

 function focusInvalid(badControl) {

  // Get the index of the tab that contains this control.
  var tabIndexToFocus = getContainingTabIndex(badControl);
  if (tabIndexToFocus > -1) {
   // Switch to the tab that contains the validation error.
   var ultraTab = igtab_getTabById('<%=UltraWebTab1.ClientID %>');
   ultraTab.setSelectedIndex(tabIndexToFocus);

   // Focus the control and exit.
   badControl.focus();
  }
 }

 function getContainingTabIndex(targetControl) {

  var ultraTab = igtab_getTabById('<%=UltraWebTab1.ClientID %>');

  // Get a reference to the tabs.
  var tabs = ultraTab.Tabs;

  // Start with the control that failed to validate.
  var testControl = targetControl;

  // While we have not got to the top of the control heirarchy...
  while (targetControl != null) {
   // Walk up the DOM to seek out the tab panel that contains the target control.
   testControl = testControl.parentNode;

   // For each of the tab panels...
   var tabIndex;
   for (tabIndex = 0; tabIndex < tabs.length; tabIndex++) {
    if (tabs&#91;tabIndex&#93;.elemDiv == testControl) {
     return tabIndex;
    }
   } // Next index.

  } // Wend.

  // The control is not contained in a tab panel.
  return -1;
 }        
&#91;/sourcecode&#93;

To run the script, attatch it to the OnClientClick event of the button that submits the form:

&#91;sourcecode language='html'&#93;<asp:Button ID="btnSubmit" runat="server" Text="Submit" OnClick="btnEdit_Click" OnClientClick="preValidate();" />

Note that validation of each control will happen twice if the form is valid; once for the code above and then once because that’s how ASP.Net works. This doesn’t normally matter with the standard ASP.Net controls, but it might be an issue with badly-written custom validation controls. You have been warned!


More .Net Cheat Sheets

December 1, 2008

I have linked a few before.

There are more here:


MS Ajax Tab Control: Index of the Tab Containing a Control

August 18, 2008

The Problem

I have an Ajax Tab Container that contains several tabs. On one of those tabs, I have a control. On the client, how do I find out which tab contains the control?

The Solution

Here is the JavaScript function that I used to solve this problem. In the following code, my tab container is called “Main_TabContainer”.  

function getContainingTabIndex(targetControl)
{

  // Get a reference to the tabs.
  var tabs = $find('&lt;%=Main_TabContainer.ClientID %&gt;').get_tabs();

  // Start with the control that we want to find.
  var testControl = targetControl;

  // While we have not got to the top of the control heirarchy, nor found the control...
  while (targetControl!=null)
  {

    // For each of the tab panels...
    var tabIndex;
    for (tabIndex = 0; tabIndex &lt; tabs.length; tabIndex++)
      {
      // If this tab panel contains the target control...
      if (tabs[tabIndex].get_element() == testControl)
      {
        return tabIndex;
        break;
      }
    } // Next index.

    // Walk up the DOM to seek out the tab panel that contains the target control.
    testControl = testControl.parentNode;

  } // Wend.

  // The control is not contained in a tab panel.
  return -1;
}

GridView Buttons not Working?

November 23, 2007

aspnet.pngIn my experience, the most common reason for Buttons in a GridView not working is:

Read the rest of this entry »


.Net Naming Conventions

November 14, 2007

aspnet.pngWhat to call things?

Read the rest of this entry »


Product Idea: A set of ASP.Net Cheat Sheets

October 23, 2007

I think people would buy a good, comprehensice set of asp.net cheat sheets. They would include:

Read the rest of this entry »


How to Display a PDF in an ASP.Net Application

July 24, 2007

aspnet.pngIt should be easier than it is.

In Q30654, Microsoft suggest the following:

private void Page_Load(object sender, System.EventArgs e)
{
//Set the appropriate ContentType.
Response.ContentType = "Application/pdf";

//Get the physical path to the file.
string FilePath = MapPath("acrobat.pdf");
//Write the file directly to the HTTP content output stream.
Response.WriteFile(FilePath);
Response.End();
}

The only thing is, for some versions of Acrobat Reader, this doesn’t actually work. On my desktop PC (IE 6.0.2800.1106 with Acrbat Reader 6.0.2.18) this results in a blank page.

For some wierd reason, the PDF is displayed if it is rendered into the page during a post-back. When I put a button on the page and included the code above in the click event, it displayed perfectly well.

To work-around the issue, I now post a (virtually) blank page to the user’s browser. On the page there is a script to post the page back. Then I render the PDF in the postback. Not exactly elegant, but it works.

Here is my code (VB.Net-ed because that is the language favoured by my employer):

''' <summary>
''' Handles the Load event of the Page control.
''' </summary>
''' <param name="sender">The source of the event.</param>
''' <param name="e">The <see cref="T:System.EventArgs" /> instance containing the event data.</param>
Protected Sub Page_Load(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles Me.Load
 
' HACK: There is a bug in some versions of
' AcrobatReader that prevent it from rendering a
' PDF during an initial page load - it will only
' render on postback. As a work-around, we generate
' a page that does nothing except post itself back.
' We can then render the report as a PDF in the
' postback. Unfortunately, the browser "refresh"
' still doesn't work very well.

' If this is not a postback...
If Not IsPostBack Then
 
' Pretend that there is a legitimate reason for
' the spurious round-trip to the client:
' display something informative.
Response.Write("Your report is loading...")
 
' Generate a script that will post-back the
' page.
Dim script As String = String.Empty
script = "<script language=javascript>" & Page.ClientScript.GetPostBackEventReference(Me, "") & "</script>"
 
' Inject the postback script into the page.
' When the page is rendered, it will post back
' immediately.
ClientScript.RegisterStartupScript(GetType(Page), "PostIt", script)
 
Else
 
' This is a postback. Render the report.
ShowReport()
 
End If
 
End Sub
 
 
''' <summary>
''' Shows the report.
''' </summary>
Private Sub ShowReport()
' Set the appropriate ContentType.
Response.ContentType = "Application/pdf"
' Get the physical path to the file.
Dim FilePath As String = MapPath("acrobat.pdf")
' Write the file directly to the HTTP content
' output stream.
Response.WriteFile(FilePath)
Response.End()
End Sub

ASP.Net Error: Could not load file or assembly ‘System.Web.Extensions…’

July 17, 2007

aspnet.png

Recently I deployed an ASP.Net application to a production server and received an error message that began, “Could not load file or assembly ‘System.Web.Extensions…'”.

Read the rest of this entry »


Ajax: How to Maintain Scroll Position Following Postback

July 3, 2007

aspnet.pngAnother quick note to myself:  I keep forgetting how to make an ASP.Net page maintain scroll position following a post back. The answer is…

Read the rest of this entry »