Apr 22, 2010

Are you still there?

Script

(function($) {

    var options = {        
            // default options for this plugin
            idle: function() { },             // called when the user fails to respond to prompt
            idleLimit: 15 * 60 * 1000,        // 15 minutes
            message: 'Are you still there?',  // can be a jQuery object (e.g. $('#my-element-id'))
            promptDuration: 60 * 1000,        // 60 seconds
            requireButtonClick: false,        // when false, mouse/key messages cancel idle prompt

            // default options for dialog widget
            autoOpen: false,
            buttons: {
                'Yes I\'m Still Here': function() {
                    $(this).dialog('close');
                }
            },
            draggable: false,
            modal: true,
            resizable: false,
            title: 'Are You Still There?'            
        },

        // constants
        PLUGIN_NAME = 'areyoustillthere',
        IDLE_CHECK_POLLING_INTERVAL = 1000,
        DIALOG_ID = PLUGIN_NAME + '-dialog',
        PROGRESSBAR_CLASS = PLUGIN_NAME + '-progressbar',

        // private variables
        isStarted = false,
        dialogElement,
        progressBarElement,
        progressBarTimerId,
        idlePollTimerId,
        timeOfLastInteraction = new Date();

    // private functions
    function touchTimeOfLastInteraction() {

        timeOfLastInteraction = new Date();
        if (!options.requireButtonClick && dialogElement.dialog('isOpen')) {
            dialogElement.dialog('close');
        }

    }
    function showDialogIfRequired() {

        if (dialogElement.dialog('isOpen')) {
            return;
        }
        if (timeOfLastInteraction.getTime() + options.idleLimit > (new Date()).getTime()) {
            return;
        }
        dialogElement.dialog('open');

    }
    function decrementProgressBarValue() {

        var currentValue = progressBarElement.progressbar('value');
        if (currentValue === 0) {
            dialogElement.dialog('close');
            if (typeof(options.idle) === 'function') {
                options.idle();
            }
        } else {
            progressBarElement.progressbar('value', (currentValue - 1));
        }

    }
    function startProgressBarTimer() {

        if (progressBarTimerId) {
            return;
        }
        progressBarElement.progressbar('value', 100);
        var tickInterval = options.promptDuration / 100;
        progressBarTimerId = window.setInterval(decrementProgressBarValue, tickInterval);

    }
    function stopProgressBarTimer() {

        if (!progressBarTimerId) {
            return;
        }
        window.clearInterval(progressBarTimerId);
        progressBarTimerId = null;

    }
    function repositionDialog() {

        if (!dialogElement.dialog('isOpen')) {
            return;
        }
        // HACK: use setTimeout to queue a re-positioning for the next available
        // clock cycle.  without this hack, the positioning calculations appear
        // to use the pre-resized/scrolled dimensions/layout coordinates.
        window.setTimeout(function() {
            var position = dialogElement.dialog('option', 'position');
            dialogElement.dialog('option', 'position', position);
        }, 0);

    }
    function start(o) {

        // sanity checks
        if (isStarted) {
            throw 'Already started';
        }
        if (options.message instanceof jQuery && options.message.length > 1) {
            throw 'The "message" option can not contain more than 1 element';
        }

        $.extend(options, o);

        if (options.message instanceof jQuery) {
            dialogElement = options.message;
        } else {
            dialogElement = $('<div id="' + DIALOG_ID + '">' + options.message + '</div>');
        }

        dialogElement
            .dialog(options)
            .bind('dialogopen', startProgressBarTimer)
            .bind('dialogclose', stopProgressBarTimer);

        progressBarElement = $('<div class="' + PROGRESSBAR_CLASS + '"></div>')
            .appendTo(dialogElement)
            .progressbar();

        $(window.document)
            .bind('mousemove', touchTimeOfLastInteraction)
            .bind('mousedown', touchTimeOfLastInteraction)
            .bind('keydown', touchTimeOfLastInteraction);

        $(window)
            .bind('scroll', repositionDialog)
            .bind('resize', repositionDialog);

        idlePollTimerId = window.setInterval(showDialogIfRequired, IDLE_CHECK_POLLING_INTERVAL);

        isStarted = true;

    }
    function stop() {

        if (!isStarted) {
            throw 'Not started yet';
        }

        if (dialogElement.dialog('isOpen')) {
            dialogElement.dialog('close');
        }

        window.clearInterval(idlePollTimerId);
        idlePollTimerId = null;

        $(window)
            .unbind('scroll', repositionDialog)
            .unbind('resize', repositionDialog);

        $(window.document)
            .unbind('keydown', touchTimeOfLastInteraction)
            .unbind('mousedown', touchTimeOfLastInteraction)
            .unbind('mousemove', touchTimeOfLastInteraction);

        stopProgressBarTimer();
        progressBarElement
            .progressbar('destroy')
            .remove();

        dialogElement
            .unbind('dialogclose', stopProgressBarTimer)
            .unbind('dialogopen', startProgressBarTimer)
            .dialog('destroy');

        if (!(options.message instanceof jQuery)) {
            dialogElement.remove();
        }

        isStarted = false;

    }

    // public api
    if (!$[PLUGIN_NAME]) {
        $[PLUGIN_NAME] =  {
            start: start,
            stop: stop,
            isStarted: function() {
                return isStarted;
            }
        };
    }

}(jQuery));

Usage

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" >
<head>
    <title>Untitled Page</title>
    <link rel="stylesheet" href="http://ajax.googleapis.com/ajax/libs/jqueryui/1.8/themes/base/jquery-ui.css" />
    <!--<link rel="stylesheet" href="http://ajax.googleapis.com/ajax/libs/jqueryui/1.8/themes/ui-lightness/jquery-ui.css" />-->
    <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.js"></script>
    <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jqueryui/1.8.0/jquery-ui.js"></script>
    <script type="text/javascript" src="jquery.areyoustillthere.js"></script>
    <script type="text/javascript">
        $(function() {
            $.areyoustillthere.start({
                title: 'this is a better title',
                idle: function() {
                    $.areyoustillthere.stop();
                    // TODO: redirect to a sign-out page
                }
            });
        });
    </script>
</head>
<body>
</body>
</html>

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));

Jan 4, 2009

Standardize window.onbeforeunload behavior with jQuery

Background

All modern browsers support the window.onbeforeunload event. This event allows web application developers to warn users that they might abandon unsaved changes before they navigate away from a page. Under specific conditions, Internet Explorer behaves differently than other browsers like Firefox and Chrome.

The quirky behavior is observed for HREF JavaScript links (example: <a href="javascript:__doPostBack();">). Many ASP.NET Web Form Controls are fond of rendering such links.

Problem

Here's some sample code which triggers the quirky behavior. Notice that the link doesn't cause the browser to redirect to any other page; it simply calls window.alert.

<a onclick="alert('onclick');" href="JavaScript:alert('href');">I'm a link, click me</a>
<script type="text/javascript">
    window.onbeforeunload = function() { return 'onbeforeunload' };
</script>

When a user clicks on the link, the expected behavior is:

  1. alert: onclick
  2. alert: href

But it appears as if Internet Explorer doesn't care that the page isn't actually unloading and will always show the onbeforeunload message:

  1. alert: onclick
  2. onbeforeunload message is displayed
  3. alert: href

Solution

I’ve written a jQuery plug-in that modifies these types of links to achieve consistent behavior across browsers.  This is accomplished by modifying the links so that href script is actually called during the onclick event. It then sets the href attribute to “#” so that Internet Explorer won’t unnecessarily raise the onbeforeunload event.

Code

(function($) {
   $(document).ready(function() {

       $('a').filter(function() {
           return (/^javascript\:/i).test($(this).attr('href'));
       }).each(function() {
           var hrefscript = $(this).attr('href');
           hrefscript = hrefscript.substr(11);
           $(this).data('hrefscript', hrefscript);
       }).click(function() {
           var hrefscript = $(this).data('hrefscript');
           eval(hrefscript);
           return false;
       }).attr('href', '#');

   });
})(jQuery);

Usage

Assuming you've placed the code into a file named jquery.fixie-onbeforeunload.js then you can conditionally include it for Internet Explorer like this:

<html>
    <head>...</head>
    <body>
        ...
        <!--[if IE]>
            <script type="text/javascript" src="jquery.fixie-onbeforeunload.js"></script>
        <![endif]-->
    </body>
</html>

Supporting jQuery’s form.submit() in ASP.Net

Problem

Many ASP.Net server controls initiate Post Backs from the client by calling a client javascript function called __doPostBack. This function bypasses any submit handlers defined with jQuery’s submit event.

Solution

I’ve written a jQuery plug-in which replaces the __doPostBack function with one which is jQuery friendly. This function initiates the Post Back by calling jQuery’s submit method instead of the form’s native submit method.

Code

(function($) {
    $(document).ready(function() {
        window.__doPostBack = function(eventTarget, eventArgument) {
            var originalvalues = [
                theForm.__EVENTTARGET.value,
                theForm.__EVENTARGUMENT.value
            ];
            theForm.__EVENTTARGET.value = eventTarget;
            theForm.__EVENTARGUMENT.value = eventArgument;
            try {
                $(theForm).submit();
            }
            finally {
                theForm.__EVENTTARGET.value = originalvalues[0];
                theForm.__EVENTARGUMENT.value = originalvalues[1];
            }
        }
    });
})(jQuery);