Mar 31, 2010

fieldvaluewatcher - Warning Users of Unsaved Changes (jQuery plug-in)

What problem does this plug-in solve?

Users have come to expect that computer applications will warn them if they are about to abandon unsaved changes. However, because of the nature of postback technology, it can sometimes be difficult to implement an accurate, robust warn-on-unsaved-changes feature in ASP.NET Web Form applications. The goal of this plug-in is to make unsaved changes warnings very easy to implement.

How do I use the plug-in?

You start by having an element which will track the original values of your form's fields. A hidden input element works very well for this because it's value is persisted across postbacks. If you're using ASP.NET then your code might look like this:

<asp:HiddenField ID="FieldValueWatcherHiddenField" runat="server" />

Initializing the plug-in on this hidden element causes the plug-in to consider the contents of this hidden field to represent the "original" values of the input fields. If the hidden field's value is empty at the time of initialization, the form's current values will be stored as the "original" values. Here's how to initialize the plug-in:

$(document).ready(function() {
    $('#<%=FieldValueWatcherHiddenField.ClientID%>').fieldvaluewatcher();
});

By default, the plug-in watches all user input controls that have an id attribute and that do not have a class named ignore-changes. You may wish to change this behavior and only watch a few controls on the page. You can control which fields are watched using standard jQuery selector syntax. If, for example, you only wanted to watch fields that have a class named watch-changes then you could configure the plug-in when you initialize it:

$(document).ready(function() {
    $('#<%=FieldValueWatcherHiddenField.ClientID%>').fieldvaluewatcher({
        fieldSelector: '.watch-changes'
    });
});

Browsers allow developers to warn users of unsaved changes via the onbeforeunload event of the window object. A typical onbeforeunload event handler will look like:

$(document).ready(function() {

    $('#<%=FieldValueWatcherHiddenField.ClientID%>').fieldvaluewatcher();

    window.onbeforeunload = function() {
        if (window.cancelUnsavedChangesWarning) {
            window.cancelUnsavedChangesWarning = false;
            return;
        }
        if ($('#<%=FieldValueWatcherHiddenField.ClientID%>').fieldvaluewatcher('hasChanges')) {
            return 'There are unsaved changes!';
        }
    };

    $('.cancelsUnsavedChangesWarning').click(function(event) {
        window.cancelUnsavedChangesWarning = true;
    });

});

Important Note: If you expect some of your users to be using Internet Explorer then you may wish to read Standardize window.onbeforeunload behavior with jQuery at this point.

It is also important to remember to "accept" changes during certain workflows. If a user clicks the "Save" button and their changes are applied to a database then warning them of unsaved changes doesn't make sense. To "accept" changes means that the current field values should now be considered the original field values. To accomplish this from server-side code, a developer can simply clear out the contents of the hidden field like this:

SaveChangesToDatabase()
FieldValueWatcherHiddenField.Value = System.String.Empty

However, if the "Save" button triggers an AJAX operation, then a mechanism for "accepting" changes from client-side code will be needed. This can be accomplished like so:

$('#<%=FieldValueWatcherHiddenField.ClientID%>').fieldvaluewatcher('acceptChanges')

Plug-in Code

/*
* Depends:
* http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js
* http://ajax.googleapis.com/ajax/libs/jqueryui/1.8.0/jquery-ui.min.js
* http://jquery-json.googlecode.com/files/jquery.json-2.2.js
*/
(function($) {

    $.widget("kmb.fieldvaluewatcher", {
        options: {
            fieldSelector: ':input:not(.ignore-changes)'
        },
        _create: function() {

            if (this.element.val().length === 0) {
                this.acceptChanges();
            }

        },
        acceptChanges: function() {

            var originalValues = this._getValues();
            var str = this._serializeValues(originalValues);
            this.element.val(str);

        },
        hasChanges: function() {

            var str = this.element.val();
            var originalValues = this._deserializeValues(str);
            var currentValues = this._getValues();
            return !this._areValuesEqual(originalValues, currentValues);

        },
        _getValues: function() {
        
            var result = {};

            $(this.options.fieldSelector).each(function() {

                var field = $(this);
                var id = field.attr('id');

                // only supported field types with an id attribute
                if (id.length !== 0 && field.is('input:checkbox, input:hidden, input:password, input:radio, input:text, select, textarea')) {

                    var value;

                    // determine the field's current value
                    if (field.is('input:checkbox, input:radio')) {
                        value = field.is('input:checked');
                    } else if (field.is('select')) {
                        value = field.val() || [];
                    } else {
                        value = field.val();
                    }

                    result[id] = value;
                }
            });

            return result;
            
        },
        _areValuesEqual: function(a, b) {

            var p;

            for (p in a) {
                // values for multi-select boxes are stored as arrays.
                // arrays are tested for equality by comparing their
                // lengths and the equality of each index.  this assumes
                // that the array of values for multi select boxes are
                // in a consistant order.
                if ((a[p] instanceof Array) && (b[p] instanceof Array)) {
                    if (a[p].length !== b[p].length) {
                        return false;
                    }
                    for (var i = 0 ; i < a[p].length ; i++) {
                        if (a[p][i] !== b[p][i]) {
                            return false;
                        }
                    }
                } else if (a[p] !== b[p]) {
                    return false;
                }
            }

            for (p in b) {
                if (typeof(a[p]) === 'undefined') {
                    return false;
                }
            }

            return true;

        },
        _serializeValues: function(values) {
        
            return $.toJSON(values);
            
        },
        _deserializeValues: function(str) {
        
            return $.evalJSON(str);
            
        }
    });
    
} (jQuery));

No comments:

Post a Comment