Introduction
My website is a rich social network that offers users many web forms to fill. For example, users can post articles and edit lengthy profiles. Often they click on a link that takes them away from the page or press the wrong key (e.g. backspace that navigates to the previous page). In both cases their changes get lost. And it is always frustrating to have to re-enter the same text twice. Wouldn't it be nice to warn the user that he has unsaved data and give him an opportunity to cancel, then save his data?
This is a Panel Extender for ASP.NET AJAX 1.0 that automatically detects if any input control inside it was changed and shows an alert if the user tries to leave the page before saving the data. The extender supports most HTML input controls and can detect whether either data, selection or both have changed.
Background
This article uses the same techniques as described in this prior AJAX DirtyPanel article, but is implemented as a panel extender for Microsoft ASP.NET AJAX 1.0. The extender model offers a very clean and straightforward solution described in the implementation section below.
Using the Code
Standard Pages
Assuming you have an ASP.NET AJAX enabled site that uses the Ajax Control Toolkit, simply add the DirtyPanelExtender
project to your solution, register the extender on the .aspx page and add an extender to a panel.
<%@ register assembly="DirtyPanelExtender"
namespace="DirtyPanelExtender" tagprefix="dp" %>
...
<dp:DirtyPaneleEtender id="demoPanelExtender" runat="server"
targetcontrolid="demoPanel"
OnLeaveMessage="There's still unsaved data on the page!" />
<asp:UpdatePanel id="demoPanel" runat="server">
...
Master Pages
The master page scenario enables all website pages to enable the dirty panel feature automatically. You must wrap the ContentPlaceHolder
in a panel and extend the panel with the DirtyPanelExtender
.
<form id="form1" runat="server">
<asp:ScriptManager ID="ScriptManager1" runat="server" />
<dp:DirtyPanelExtender ID="demoPanelExtender" runat="server"
TargetControlID="masterPanel"
OnLeaveMessage="There's still unsaved data on the page!" />
<asp:UpdatePanel ID="masterPanel" runat="server">
<ContentTemplate>
<asp:ContentPlaceHolder ID="ContentPlaceHolder1" runat="server">
</asp:ContentPlaceHolder>
</ContentTemplate>
</asp:UpdatePanel>
</form>
Implementation
Creating a Basic Extender
Creating an extender skeleton is described in this walkthrough. The basics include:
- DirtyPanelExtenderBehavior.js: all client-side script logic
- DirtyPanelExtender.cs: server-side control implementation
- DirtyPanelExtenderDesigner.cs: design-time functionality
Hooking window.onbeforeunload
The window.onbeforeunload
callback is the essential hook that will trap closing of the window. It is possible to prompt the user before the window is unloaded.
window.onbeforeunload = function (eventargs)
{
if(! eventargs) eventargs = window.event;
eventargs.returnValue = "You have unsaved data.
Are you sure you want to close this window?"
}
See MSDN for detailed information about the window.onbeforeunload
handler.
Multiple DirtyPanels
The implementation supports multiple dirty panels by creating an array of panels.
var DirtyPanelExtender_dirtypanels = new Array()
The panel initialization code that will add itself to this array.
initialize : function()
{
DirtyPanelExtender.DirtyPanelExtenderBehavior.callBaseMethod
(this, 'initialize');
DirtyPanelExtender_dirtypanels[DirtyPanelExtender_dirtypanels.length] = this;
}
It is now possible to iterate through the array in JavaScript.
for (i in DirtyPanelExtender_dirtypanels)
{
var panel = DirtyPanelExtender_dirtypanels[i];
...
}
Hooking window.onbeforeunload for Dirty Panels
Every panel will expose a panel.isDirty
that will return true
if any of the existing form fields has changed (making the panel "dirty"), plus an OnLeaveMessage
property to store the message to show. The hooking will only need to happen for a dirty panel.
window.onbeforeunload = function (eventargs)
{
for (i in DirtyPanelExtender_dirtypanels)
{
var panel = DirtyPanelExtender_dirtypanels[i];
if (panel.isDirty())
{
if(! eventargs) eventargs = window.event;
eventargs.returnValue = panel.get_OnLeaveMessage();
break;
}
}
}
Suppressing Dirty Check for Postbacks
The dirty panel only needs to trap navigating away from the page and not regular AJAX interaction built into the page. This notably enables upload controls without an UpdatePanel
.
function __newDoPostBack(eventTarget, eventArgument)
{
window.onbeforeunload = null;
return __savedDoPostBack (eventTarget, eventArgument);
}
var __savedDoPostBack = __doPostBack;
__doPostBack = __newDoPostBack;
Determining Whether a Panel is Dirty
Determining whether the panel is dirty is the hardest part. First, there's no native support for whether an input box or other editable control has changed. Old values must be tracked and compared. In addition, hidden values should not be updated on a regular postback. Original values are saved in a hidden field in OnPreRender
.
protected override void OnPreRender(EventArgs e)
{
string values_id = string.Format("{0}_Values", TargetControl.ClientID);
string values = (Page.IsPostBack ?
Page.Request.Form[values_id] : String.Join(",", GetValuesArray()));
ScriptManager.RegisterHiddenField(this, values_id, values);
base.OnPreRender(e);
}
The implementation of GetValuesArray
simply iterates through child controls and saves those that are editable. Special care is taken for various types of controls.
ListControl
types, including DropDownList
and ListBox
: save both data and initial selectionsRadioButtonList
: save an entry for each radio button with its selected state; radio button contents don't workIEditableTextControl
: save any .Text
value of an editable controlICheckBoxControl
: save checkbox state
Note that it now looks trivial to implement a way to reset the dirty flag, for example when the user presses the Save button. It is only necessary to reset the saved values. Unfortunately things are not that simple, especially if the extender is used with an UpdatePanel
. You must emit JavaScript within that panel that will reset the value of the hidden field.
public void ResetDirtyFlag()
{
ScriptManager.RegisterClientScriptBlock
(TargetControl, TargetControl.GetType(),
string.Format("{0}_Values_Update", TargetControl.ClientID),
string.Format("document.getElementById('{0}').value = '{1}';",
string.Format("{0}_Values", TargetControl.ClientID),
String.Join(",", GetValuesArray())), true);
}
The isDirty
function deconstructs the hidden field value and compares the current form values one-by-one, for each type of input control.
isDirty : function() {
var values_control = document.getElementById(this.get_element().id +
"_Values");
var values = values_control["value"].split(",");
for (i in values) {
var namevalue = values[i];
var namevaluepair = namevalue.split(":");
var name = namevaluepair[0];
var value = (namevaluepair.length > 1 ? namevaluepair[1] : "");
var control = document.getElementById(name);
if (control == null) continue;
if (control.type == 'checkbox' || control.type == 'radio') {
var boolvalue = (value == "true" ? true : false);
if(control.checked != boolvalue) {
return true;
}
} else if (control.type == 'select-one') {
if ( control.size > 0 ){
...
if( encodeURIComponent(optionValues) != value ){
return true;
}
} else if(control.selectedIndex != value) {
return true;
}
} else {
if(encodeURIComponent(control.value) != value) {
return true;
}
}
}
return false;
}
Dealing with Lists
The actual implementation of isDirty
is a little more complex, especially for lists. These typically inherit from ListControl
. It is necessary to support both selection and data changes in the list, and GetValuesArray
creates two hidden variables, id:selection:value
and id:data:value
to represent the current state.
else if (control is ListControl)
{
StringBuilder data = new StringBuilder();
StringBuilder selection = new StringBuilder();
foreach (ListItem item in ((ListControl) control).Items)
{
data.AppendLine(item.Text);
selection.AppendLine(item.Selected.ToString().ToLower());
}
values.Add(string.Format("{0}:data:{1}", control.ClientID,
Uri.EscapeDataString(data.ToString())));
values.Add(string.Format("{0}:selection:{1}", control.ClientID,
Uri.EscapeDataString(selection.ToString())));
}
isDirty
will process both types of values.
} else if (control.type == 'select-one' || control.type == 'select-multiple')
{
if (namevaluepair.length > 2) {
if ( control.options.length > 0) {
var code = value;
value = (namevaluepair.length > 2 ? namevaluepair[2] : "");
var optionValues = "";
for( var cnt = 0; cnt < control.options.length; cnt++) {
if (code == 'data') {
optionValues += control.options[cnt].text;
} else if (code == 'selection') {
optionValues += control.options[cnt].selected;
}
optionValues += "\r\n";
}
if( encodeURIComponent(optionValues) != value ) {
return true;
}
}
} else if(control.selectedIndex != value) {
return true;
}
Conclusion
This is a simple and useful control. I also found the ASP.NET AJAX extender model very well structured and clean, adding useful functionality to existing controls in a straightforward manner, a significant improvement over the reference AJAX implementation for Anthem.
Known Issues
- bug: doesn't work with Opera; tested with Opera 9.21
- bug: doesn't work with Safari; tested with 3.0.2 WinXP
- bug: partial support for
RadioButtonList
- selection changes only, no dynamic data changes
History
- 08/10/2007: initial version
- 08/11/2007: fixed bug - target control client ID wrong
- 08/11/2007: fixed bug - fixed for upload controls and standard AJAX scenarios; suppressed prompting for all postbacks
- 08/13/2007: added demo and documentation for using the extender with master pages
- 08/28/2007: added
RadioButtonList
and ListBox
support and demo for both data and selection (thanks to David Christensen)