Title: ASP.NET Common Web Page Class Library - Part 2
Author: Eric Woodruff
Email: Eric@EWoodruff.us
Environment: Visual Studio .NET, IIS, ASP.NET, C#, VB.NET
Keywords: page, template, detect, data, change
Level: Intermediate
Description: Detecting changes in data controls in ASP.NET web forms
Section ASP.NET
SubSection General
Table of Contents
Introduction
This is the second in a series of articles on a class library for
ASP.NET applications that I have developed. It contains a set of common,
reusable page classes that can be utilized in web applications as-is to
provide a consistent look, feel, and set of features. New classes can also
be derived from them to extend their capabilities. The features are all
fairly modular, and may be extracted and placed into your own classes too.
For a complete list of articles in the series along with a demonstration
application and the code for the classes, see Part
1 [^ ].
This article describes the data change checking features of the
BasePage class. This is a fairly large section, so it was
split out from the first article to keep it from getting too long. It
covers the properties and methods related to the change checking features
of the BasePage class as well as the client-side code used to
implement it.
Data Change Checking
In a rich-client application, it is fairly easy to detect changes in
data entry controls and warn the user that they are about to lose their
changes when they attempt to leave the form. You also have full control
over how they can leave the form or the application. In a web-based
application, it is more difficult. You have some chance of catching the
attempt to leave if they click a link or button on your page, but they may
also leave by entering a new URL in the browser's address text box, they
may navigate away by using the back, forward, or history links, or they may
just close the browser. It can be quite irritating for the end-user to
navigate away from the page only to realize that they did not save their
changes first. Telling them "Then don't do that" just makes them cranky.
The BasePage class and those derived from it provide a set
of properties that will allow you to trap most attempts to leave the web
form before the user has saved their changes, and ask them whether or not
they really want to leave. When enabled, it will also track the dirty state
of the form automatically. While it is not 100% effective, it does a pretty
good job in most situations. With that said, here are the limitations:
- This only works in Internet Explorer 4+ as it relies on the
OnBeforeUnload event on the browser's window object. For
non-IE browsers, the only support provided is dirty state tracking via the
BasePage.Dirty property. It has only been tested in IE, FireFox,
and Netscape so other browsers may still have issues not addressed here.
- If you decide to leave anyway, you may get a double prompt about trying
to leave without saving, in some cases. This usually occurs on links in
DataGrid web controls (i.e., an "Add" command link button).
This is more of an annoyance than anything.
- It will exit without prompting if the active element is one you do not
want to get prompted on (a Save button, for example), but it has the focus
and you leave by doing something that does not interact with the page such
as closing the browser, clicking the back button, etc. This is a rare case,
but it can happen.
- It may exit without prompting if a control has
AutoPostBack enabled, and you leave the page without modifying
any controls on the page after an auto-postback has occurred. This problem
can be circumvented by the use of the BasePage.Dirty property.
When BasePage.CheckForDataChanges is set to true, the client-side code will detect changes and the page
will automatically track the dirty state for you. You can also explicitly
set it to true in cases where no changes may have
occurred client-side so that the user will get prompted if they attempt to
leave the page later on (i.e., a button is clicked to fill in default
values on a new record).
- It only looks at the standard HTML controls on the page (text box, text
area, checkbox, radio button, and select controls such as dropdown lists
and list boxes). Since almost all of the ASP.NET web controls render as one
of the standard HTML controls, this does not present any problems. However,
if you use any ActiveX controls on the web page for data entry, they will
not be checked.
How It Works
The BasePage class contains five properties that work
together with a client-side JavaScript function to provide data change
checking.
| Property |
Description |
CheckForDataChanges |
Set this Boolean property to true to enable
data change checking. Set it to false (the
default) to disable it. When enabled, the page will render a hidden
field, some extra JavaScript variables, and a function to check for
changes. The function is bound to the window object's
OnBeforeUnload event when the page loads. It is also
registered as an OnSubmit statement to allow automatic
tracking of the dirty state. Changes to the BP_bIsDirty
hidden field cannot occur in the OnBeforeUnload event so
they must be made in the OnSubmit event instead. |
Dirty |
This is a Boolean property that you can set to true to force the page to always prompt the user about
saving the changes prior to leaving. This is most useful in cases where
a postback has occurred to modify the state of the form (i.e., to
disable controls or load different values based on a selection in a
dropdown list). It will automatically get set to true by the client-side code if changes have been made
to the form controls. The CheckForDataChanges property
must be set to true in order to use this property.
The prompting behavior is only available in Internet Explorer. For all
other browsers, it only tracks the dirty state. Do not forget to set
this property to false after saving or canceling
edits to the page. |
ConfirmLeaveMessage |
This string property contains the message that will be displayed
when the user attempts to leave without first saving their changes
(Internet Explorer only). If not set, a default message is
displayed. |
BypassPromptIds |
This property is set to a string array of control IDs that should
not cause prompting even if changes occur (i.e., a Save or Cancel
button). When postback occurs due to a control with one of these IDs,
no prompting will occur even if changes have been made (Internet
Explorer only). The dirty flag will still get updated. If not set,
all controls that cause a postback will result in prompting if data
has been changed. |
SkipDataCheckIds |
This property is set to a string array of control IDs that should
be ignored when checking for changed data. For example, you may have a
read-only text box used to display text or messages that gets updated
during the course of using the form. Since it is not part of the saved
information, it can be ignored and will not prevent leaving the page if
it is the only thing that changed. If not set, all data entry controls
are checked for changes. |
Change checking is usually enabled in the Page_Load event
the first time the page is loaded. The CheckForDataChanges
property is set to true and the
BypassPromptIds property is set to a list of controls that
should not cause prompting to occur (i.e., a Save button, dropdown lists
with AutoPostBack enabled, etc.). For example:
private void Page_Load(object sender, System.EventArgs e)
{
if(Page.IsPostBack == false)
{
// Set up form for data change checking when
// first loaded.
this.CheckForDataChanges = true;
this.BypassPromptIds =
new string[] { "btnSave", "btnCancel",
"chkLimitToTeam" }
}
}
If specifying the IDs of such controls as the sort links in data grid
headers, you will need to run the page, view the source, and get the names
of the link controls from the rendered HTML. Be sure to change the '$'
characters in the names to ':' when specifying them in the bypass list, as
the __doPostback() function changes them before the data
changing checking code gets the ID.
Changes in the data controls can only be detected from the point at
which the user starts interacting with the page up to the point at which a
postback occurs. If you have controls on the page that cause a postback,
such as a button that alters the state of some controls but it does not
actually save changes made up to that point, you may need to make use of
the Dirty property. This is needed because once the page is
rendered after the postback, it has no idea what the original values were
prior to that. Setting the Dirty property to true in the event handler for such postbacks will ensure
that the user is prompted to save their changes before leaving the page.
All it does is set a flag that the JavaScript function checks and, if true,
always prompts the user to save their changes regardless of whether or not
anything has actually been changed since the page was rendered.
Generating the Client-side Code
The rendering of the client-side variables and script occurs in the
overridden OnPreRender() method. The code is fairly
straightforward, and simply uses a StringBuilder object to
format the variables and script code and then registers it with the page.
The script is stored as a resource in the assembly so that you do not have
to distribute it separately along with the assembly. Also, to insure that
the correct form is affected by the code, a variable is rendered that
contains the form's unique ID. While ASP.NET does limit you to one form
with a runat='server' attribute, it will let you
have other regular HTML forms on the page without that attribute. By
enforcing use of the web form's client-side name, it will not break any
pages that may utilize other regular HTML forms. The code below is for the
.NET 1.1 version of the class. The .NET 2.0 version varies only slightly
with its use of the Page object's new methods for registering
the script blocks, submit statement, and the hidden field.
protected override void OnPreRender(EventArgs e)
{
StringBuilder sb;
string[] idList;
base.OnPreRender(e);
// If data change checking has been requested, output the
// dirty flag, exclusion arrays, confirm message, and the
// script.
if(this.CheckForDataChanges == true)
{
// Register a hidden field so that the client can pass
// back changes to the Dirty flag.
this.RegisterHiddenField("BP_bIsDirty",
this.Dirty.ToString(
CultureInfo.InvariantCulture).ToLower(
CultureInfo.InvariantCulture));
// Register an OnSubmit function call so that we can
// get the state of the Dirty flag and put it in the
// hidden field. It can't occur in the OnBeforeUnload
// event as everything has been packaged up ready for
// sending to the server and changes made in that event
// don't get sent to the server.
this.RegisterOnSubmitStatement("BP_DirtyCheck",
"BP_funCheckForChanges(true);");
// Create a script block containing the array
// declarations, the data loss message variable, the
// dirty flag, and the change checking script.
sb = new StringBuilder(
"<script type='text/javascript'>\n<!--\n" +
"var BP_arrBypassList = new Array(", 4096);
idList = this.BypassPromptIds;
if(idList != null)
{
sb.Append('\"');
sb.Append(String.Join("\",\"", idList));
sb.Append('\"');
}
sb.Append(");\nvar BP_arrSkipList = new Array(");
idList = this.SkipDataCheckIds;
if(idList != null)
{
sb.Append('\"');
sb.Append(String.Join("\",\"", idList));
sb.Append('\"');
}
sb.Append(");\nvar BP_strDataLossMsg = \"");
sb.Append(this.ConfirmLeaveMessage);
sb.Append("\";\nvar BP_strFormName = \"");
// BP_strFormName tells the script what form to use
// for the change checking.
sb.Append(this.PageForm.UniqueID);
sb.Append("\";\n//-->\n</script>\n");
// Add the reference to retrieve the script from the
// resource server handler.
sb.Append("<script type='text/javascript' src='");
sb.Append(ResSrvHandler.ResSrvHandlerPageName);
sb.Append("?Res=DataChange.js'></script>");
this.RegisterClientScriptBlock("BP_DCCJS",
sb.ToString());
}
...
}
The OnInit method is overridden to retrieve the value of
the hidden field when the page is created on the server. If change checking
is enabled, it will set the Dirty property to the value of the
hidden field as determined on the client during postback. If change
checking is disabled, it will always be set to false,
as the field will not exist.
protected override void OnInit(EventArgs e)
{
base.OnInit(e);
// Retrieve the state of the Dirty flag (if used)
this.Dirty = Convert.ToBoolean(Request.Form["BP_bIsDirty"],
CultureInfo.InvariantCulture);
...
}
The JavaScript Code
The JavaScript code is what makes it all work on the client side. When
rendered, it consists of a hidden field that is set to the current value of
the Dirty property, a set of variables that are populated with
the values from the ConfirmLeaveMessage,
BypassPromptIds, and SkipDataCheckIds properties,
and the JavaScript functions BP_funCheckForChanges() and
BP_OnSubmit(). Inline JavaScript code at the end of the script
binds the change checking function to the window object's
OnBeforeUnload event.
The change checking function is also registered as part of the form's
OnSubmit attribute to allow it to detect changes and set the
hidden field to true. The client-side code module
also substitutes the BP_OnSubmit() function as the web form's
real Submit method. The original handler is saved as a new
BP_RealOnSubmit method on the form. The function will call the
original handler after calling the change checking function to update the
dirty state flag. The reason for this extra work is that controls with
AutoPostBack enabled, such as dropdown lists, call the form's
Submit method directly and it does not fire the
OnSubmit event. By substituting our own handler, we can still
track changes to the form data in these cases.
// Replace the OnSubmit event. This is so that we can always
// update the state of the Dirty flag even when controls with
// AutoPostBack cause the submit.
document.forms[BP_strFormName].BP_RealOnSubmit =
document.forms[BP_strFormName].submit;
function BP_OnSubmit()
{
BP_funCheckForChanges(true);
// It sometimes reports an error if OnBeforeUnload
// cancels it. Ignore it.
try {
document.forms[BP_strFormName].BP_RealOnSubmit();
} catch(e) { }
}
document.forms[BP_strFormName].submit = BP_OnSubmit;
// IE Only: Hook up the event handler for OnBeforeUnload
window.onbeforeunload = BP_funCheckForChanges;
When the user tries to leave the page, the change checking function is
called and the following steps occur:
function BP_funCheckForChanges(bInOnSubmit)
{
var strID, nIdx, nNumOpts, nSkipCnt, nPos, nOptIdx;
var oElem, oOptions, oOpt, nDefSelIdx, nSelIdx;
var oForm = document.forms[BP_strFormName];
var nElemCnt = oForm.elements.length;
var bPrompt = (typeof(bInOnSubmit) == "undefined");
var ctlDirty = document.getElementsByName("BP_bIsDirty")[0];
// This prevents a double prompt that can occur when post
// back is not cancelled for a hyperlink-type control.
// Not sure why it happens but this works around it.
if(bPrompt == true && BP_bOnBeforeUnloadFired == true)
return;
// Get current state of Dirty flag
var bChanged = (ctlDirty.value == "true");
The first part initializes several variables for the function. The
bPrompt variable is initially set to true or false based on
whether or not a parameter was passed to the function. When passed a
parameter, it has been called as part of the OnSubmit event
and should never prompt, as we are only interested in updating the state of
the dirty flag. When called as part of the OnBeforeUnload
event, it should prompt unless told not to later on because of something in
the bypass ID list. The reason for the separate calls is that the
OnBeforeUnload event occurs after everything has been packaged
up ready for sending to the server, and thus changes to the hidden field do
not get sent back if made in that event. A side effect of this is that for
non-IE browsers, you at least get dirty state tracking. The
bChanged variable is set to the current value of the
BP_bIsDirty hidden field. This allows it to maintain the dirty
state and prompt to save changes even after a postback in which nothing
else changed. Note that in order to get a reference to the hidden field, we
have to use the document.getElementsByName method. This is
because the page renders the registered hidden fields with a
name attribute but without an id attribute. Using
the "ByName" method insures that the code will work on non-IE browsers.
// IE Only: The event target is most likely the item that
// caused the request to leave the page. If it's in the list
// of controls that can bypass the check, don't prompt. The
// control ID must be an exact match or must end with the name
// (i.e. it's in a DataGrid).
strID = "";
oElem = document.getElementById("__EVENTTARGET");
if(oElem == null || typeof(oElem) == "undefined" ||
oElem.value == "")
{
// Check the active element if there is no event target
if(typeof(document.activeElement) != "undefined")
{
oElem = document.activeElement
strID = oElem.id;
}
}
else
strID = oElem.value;
// Some elements may not have an ID but their parent element
// might so grab that if possible (i.e. AREA elements in a MAP
// element).
if(strID == "" && oElem != null &&
typeof(oElem) != "undefined")
{
// Link buttons in DataGrids don't have IDs but do use
// __doPostBack(). If we see a link with that in its href,
// assume __doPostBack() is running and skip the check.
// The submission will call us again.
if(oElem.tagName == "A" &&
oElem.href.indexOf("__doPostBack") != -1)
return;
if(typeof(oElem.parentElement) != "undefined")
strID = oElem.parentElement.id;
}
if(strID != "")
{
nSkipCnt = BP_arrBypassList.length;
for(nIdx = 0; nIdx < nSkipCnt; nIdx++)
if(strID == BP_arrBypassList[nIdx])
bPrompt = false;
else
{
nPos = strID.length - BP_arrBypassList[nIdx].length;
if(nPos >= 0)
if(strID.substr(nPos) == BP_arrBypassList[nIdx])
bPrompt = false;
}
}
For Internet Explorer only, the event target element (the one that
caused the postback) is checked to see if its control ID is in the bypass
list. If so, the bPrompt flag is set to false so that no prompting occurs. For example, you do
not want it to prompt the user to save their changes when the Save
button is clicked. When checking the control ID, it looks for an exact
match or one that ends in the ID from the list. The "ends with" match is
there to handle controls embedded in DataGrid web controls.
Their client-side IDs are altered based on the row they appear in to keep
them unique. Since we do not know the unique ID, the partial match based on
the ID assigned at design time will work.
// Now we'll figure out if something changed
nSkipCnt = BP_arrSkipList.length;
for(nIdx = 0; !bChanged && nIdx < nElemCnt; nIdx++)
{
oElem = oForm.elements[nIdx];
// If the control is in the list of ones to ignore,
// carry on.
for(nOptIdx = 0; nOptIdx < nSkipCnt; nOptIdx++)
{
if(oElem.id == BP_arrSkipList[nOptIdx])
break;
nPos = oElem.id.length - BP_arrSkipList[nOptIdx].length;
if(nPos >= 0)
if(oElem.id.substr(nPos) == BP_arrSkipList[nOptIdx])
break;
}
if(nOptIdx < nSkipCnt)
continue;
// Check for changes based on the control type
if(oElem.type == "text" || oElem.tagName == "TEXTAREA")
{
if(oElem.value != oElem.defaultValue)
bChanged = true;
}
else
if(oElem.type == "checkbox" || oElem.type == "radio")
{
if(oElem.checked != oElem.defaultChecked)
bChanged = true;
}
else
if(oElem.tagName == "SELECT")
{
oOptions = oElem.options;
nNumOpts = oOptions.length;
nDefSelIdx = nSelIdx = 0;
// Search for a change in the default. If
// nothing is explicitly marked as the default,
// element zero is assumed to have been the
// default.
for(nOptIdx = 0; nOptIdx < nNumOpts; nOptIdx++)
{
oOpt = oOptions[nOptIdx];
if(oOpt.defaultSelected)
nDefSelIdx = nOptIdx;
if(oOpt.selected)
nSelIdx = nOptIdx;
}
if(nDefSelIdx != nSelIdx)
bChanged = true;
}
}
Next, each control is checked to see if the data it contains has been
changed. If the control ID is in the list of elements to skip, it will be
ignored. This allows you to have controls on the page that can be modified
without causing it to prompt to save changes (i.e., message text areas
etc.). As with the bypass list, the control ID can be an exact match or one
ending in the specified ID.
Changes are detected based on the control type. For text boxes and text
areas, the value property is compared to the
defaultValue property. For checkboxes and radio buttons, the
checked property is compared to the
defaultChecked property. For select controls
(dropdown lists and list boxes), each item in the collection is scanned.
The current selection is found and compared to the item that was marked as
the default selection to see if there was a change. If no item in the list
was marked as the default, the first element is considered to be the
default.
if(bChanged)
{
// Pass the dirty state back to the server
ctlDirty.value = "true";
// If prompting, set the message
if(bPrompt)
{
event.returnValue = BP_strDataLossMsg;
BP_bOnBeforeUnloadFired = true;
window.setTimeout("BP_funClearIfCancelled()", 1000);
}
}
If no changes were found and the Page class's dirty flag
is still false, the function exits and no prompting occurs. No prompting
occurs if called as part of the OnSubmit event either.
For Internet Explorer, if a change was detected, or the page class's
dirty flag was set to true and it was called as part of the
OnBeforeUnload event, the event.returnValue
property is set to the confirmation message. This causes the browser to pop
up a message box asking whether or not it is okay to leave the page. The
confirmation message is displayed in the message box preceded by the
question "Are you sure you want to navigate away from this page?" and
followed by instructions to click OK to continue or Cancel to stay on the
current page. Those two messages are added by the browser and cannot be
changed. Only the text that you supply that appears in the middle can be
modified via the ConfirmLeaveMessage property.
In addition to setting the message, we also set a flag variable
(BP_bOnBeforeUnloadFired) that prevents a double prompt that
would normally occur under certain conditions. It also sets up a time out
event that calls the BP_funClearIfCancelled function. This
serves two purposes if post-back is cancelled. It clears the flag that
prevents the double prompt, and it also clears __EVENTTARGET
as it doesn't get cleared if you cancel an auto-postback item and then
click a button, for example. If not cleared, it could cause unexpected
behavior in the server side code in some cases where that variable is used.
Conclusion
I have used the BasePage class and a few derived from it
in all of my ASP.NET applications, to give them a consistent look, feel,
and set of features. The data change checking features, though full use is
limited to Internet Explorer, have been extremely helpful in eliminating a
common user complaint about losing data due to forgetting to save changes
before leaving a page. Hopefully, you will find this class and the others
in the library, or parts of them, as useful as I have.
Revision History
| 04/02/2006 |
|
Breaking Changes: Property and method names have been
modified to conform to the .NET naming conventions with regard to
casing (BasePage.BypassPromptIds and
BasePage.SkipDataCheckIds). |
| |
| 11/26/2004 |
|
Changes in this release:
- Made some changes based on suggestions from Danny Dot Net to
prevent the double prompt when posting back via a hyperlink-type
control. A few other changes were made that also eliminate the
double prompt in almost all other situations as well.
- Fixed up a potential problem with the __EVENTTARGET variable
not being cleared if post back is cancelled.
|
| |
| 12/01/2003 |
|
Initial release |