3
\$\begingroup\$

A while back I posted Wrapper for jquery ajax to ensure redirects occur on clientside on handling ajax and redirects when a server side error occurs.

Since that post I've been using this module extensively (in MVC.net projects) and it works really well in conjuction with some serverside code to help implement the redirects (especially unauthorized). I thought I would re-post to get some feedback and help improve as it has changed a wee bit.

Background

I often had issues with ajax requests when server side errors occurred, or especially when the user was logged out and an Ajax event fired. I would either get the resultant login page in my view (if I was rendering to the view) or I might not get anything. Hence I wanted a way that I would be redirected if this event occurs much like it does on the serverside already when a user is not authenticated.

Setup

In my latest MVC project I've used it by:

  1. Adding via nuget the AspNetHaack project
  2. Adding global filter to supply redirect URLs back to the JavaScript (HttpAjaxRedirectFilter)
  3. Adding a spinner icon for the spinner version (ajaxWithSpinner)
  4. The javascript itself (ajaxWithRedirect and subsequent wrappers)
  5. Some css to work with the spinner option

Serverside code

public class HttpAjaxRedirectFilter : IActionFilter
{
    public void OnActionExecuting(ActionExecutingContext filterContext)
    {
        var modelState = filterContext.Controller.ViewData.ModelState;

        if (!modelState.IsValid
            && IsPost(filterContext.RequestContext.HttpContext)
            && filterContext.RequestContext.HttpContext.Request.IsAjaxRequest())
        {
            // If the model state is not valid for ajax requests then we don't even want to carry on with the action. In theory all of our forms
            // should be using clientside validation so it should all be caught there so any posts that make it through are via some other mechanism
            filterContext.HttpContext.Response.StatusCode = 400;

            filterContext.Result = NewtonSoftJson(new
            {
                Message = "The data supplied was invalid",
                Data = modelState
            });
        }
    }

    public void OnActionExecuted(ActionExecutedContext filterContext)
    {
        var redirectResult = filterContext.Result as RedirectToRouteResult;

        if (redirectResult != null &&
            !filterContext.Canceled &&
            filterContext.HttpContext.Request.IsAjaxRequest())
        {
            var values = redirectResult.RouteValues.Values.ToList();
            string actionName = values[0] as string;
            string controllerName = values[1] as string;

            UrlHelper urlHelper = new UrlHelper(filterContext.HttpContext.Request.RequestContext);
            filterContext.Result = JsonRedirectToAction(
                                        urlHelper, 
                                        actionName, 
                                        controllerName, 
                                        redirectResult.RouteValues);
        }
    }

    protected bool IsPost(HttpContextBase httpContext)
    {
        return httpContext.Request.HttpMethod == "POST";
    }

    protected JsonResult NewtonSoftJson(object obj, JsonRequestBehavior behaviour = JsonRequestBehavior.AllowGet)
    {
        return new NetwonSoftJsonActionResult
        {
            Data = obj,
            JsonRequestBehavior = behaviour
        };
    }

    protected JsonResult JsonRedirectToAction(
                             UrlHelper urlHelper, 
                             string actionName, 
                             string controllerName, 
                             RouteValueDictionary routeValues)
    {
        return NewtonSoftJson(new
        {
            RedirectUrl = urlHelper.Action(actionName, controllerName, routeValues)
        });
    }
}

public class NetwonSoftJsonActionResult : System.Web.Mvc.JsonResult
{
    public NetwonSoftJsonActionResult()
    {
        JsonRequestBehavior = JsonRequestBehavior.AllowGet;
    }

    public NetwonSoftJsonActionResult(object data)
        : this()
    {
        Data = data;
    }

    public override void ExecuteResult(System.Web.Mvc.ControllerContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException("context");
        }

        if (JsonRequestBehavior == JsonRequestBehavior.DenyGet &&
            String.Equals(context.HttpContext.Request.HttpMethod,
                "GET", StringComparison.OrdinalIgnoreCase))
        {
            throw new InvalidOperationException("MvcResources.JsonRequest_GetNotAllowed");
        }

        HttpResponseBase response = context.HttpContext.Response;

        if (!String.IsNullOrEmpty(ContentType))
        {
            response.ContentType = ContentType;
        }
        else
        {
            response.ContentType = "application/json";
        }

        if (ContentEncoding != null)
        {
            response.ContentEncoding = ContentEncoding;
        }

        if (Data != null)
        {
            var serializer = new Newtonsoft.Json.JsonSerializer();
            var txr = new Newtonsoft.Json.JsonTextWriter(context.HttpContext.Response.Output);
            {
                serializer.Serialize(txr, this.Data);
            }
        }
    }
}

Clientside module

// base namespace
var site = site || {};
var Domain = Domain || { HomeUrl: "/" };

if (typeof String.prototype.startsWith != 'function') {
    // see below for better implementation!
    String.prototype.startsWith = function (str) {
        return this.indexOf(str) == 0;
    };
}

site.core = {

    toggleOverlay: function ($element, overlayId, showOverlay) {
        showOverlay ? this.addOverlay($element, overlayId) : this.removeOverlay("#" + overlayId);
    },

    addSpinnerOverlay: function ($element, overlayId) {

        // If the overlay already exists we don't need to re-add it
        if ($element.children(overlayId).length) return;

        var $overlay = this.addOverlay($element, overlayId);
        $overlay.html('<div class="spinner" ">');
    },

    addOverlay: function ($element, overlayId) {
        var pos = $.extend({
            width: $element.outerWidth(),
            height: $element.outerHeight()
        }, $element.position());

        var $overlay = $('<div>', {
            id: overlayId,
            css: {
                position: 'absolute',
                top: pos.top,
                left: pos.left,
                width: $element.outerWidth(),
                height: $element.outerHeight(),
                backgroundColor: '#fff',
                opacity: 0.50
            }
        });

        $overlay.appendTo($element);

        return $overlay;
    },

    removeOverlay: function (overlayId) {
        $(overlayId).remove();
    },

    redirect: function (url) {
        window.location = url;
    },
};

$.extend({

    // $.ajaxWithRedirect
    //
    // Provides a transparent implemention of the jQuery $.ajax method with the additional alternative of being
    // able to automatically redirect when a Json object is returned with the RedirectUrl parameter.
    //
    // If the caller wants to perform or cancel the redirect they can provide a beforeRedirect function
    // and return false.        
    ajaxWithRedirect: function (options) {
        "use strict";

        var deferred = $.Deferred(), successHandler, errorHandler, xhr;

        // force no-cache
        options.cache = false;

        // get a copy of the success handler...
        successHandler = options.success;

        errorHandler = options.error;

        options.error = function (data) {

            //supress the callers error callback, since the options.statusCode below will redirect anyway, or, a status of zero indicates the window is navigating away to another page.
            if (data.status == 0 || data.status == 401 || options.status == 403) {
                return;
            }

            if (typeof errorHandler === 'function') {
                errorHandler.apply(null, arguments);
                deferred.resolve.apply(null, arguments);
            } else {
                alert('Unfortunately something went wrong.  Please contact us for more information.');
            }
        };

        // ... and replace it with this one
        options.success = function (data) {
            var contentType = xhr.getResponseHeader("Content-Type"),
                performRedirect = true,
                redirectUrl = null,
                args = [].slice.call(arguments, 0) || {},
                json;

            // If response isn't there or isn't json,
            // skip all the redirect logic
            if (data && (/json/i).test(contentType)) {
                // If json was requested, and json received, jQuery will
                // have parsed it already. Otherwise, we'll have to do it
                if (options.dataType === 'json' || (data && data.RedirectUrl)) {
                    redirectUrl = data.RedirectUrl;
                } else {
                    try {
                        json = $.parseJSON(data);
                        redirectUrl = json.RedirectUrl;
                    } catch (e) {
                        // not json
                    }
                }

                // check the redirect url
                if (redirectUrl && typeof redirectUrl === 'string') {
                    // Is there a beforeRedirect handler?
                    if (typeof options.beforeRedirect === 'function') {
                        // pass all the arguments to the beforeRedirect handler
                        performRedirect = options.beforeRedirect.apply(null, args);
                    }

                    // unless strictly false, go ahead with the redirect
                    if (performRedirect !== false) {
                        site.core.redirect(redirectUrl);
                        // and stop here. No success and/or deferred handlers
                        // will be called since we're redirecting anyway
                        return;
                    }
                }
            }

            // no redirect; forward everything to the success handler(s)
            if (typeof successHandler === 'function') {
                successHandler.apply(null, args);
                deferred.resolve.apply(null, args);
            }
        };

        // Handling when the user is currently not authorized but they have tried to implement an ajax related action
        options.statusCode =
        {
            401: function (data) {
                var redirectUrl = (data && data.responseText && data.responseText.length > 0) ? data.responseText : Domain.HomeUrl;

                site.core.redirect(redirectUrl);
            },
            403: function (data) {
                site.core.redirect("/");
            }
        };

        // Make the request
        xhr = $.ajax(options);

        // Forward the deferred promise method(s)
        xhr.fail(deferred.reject);
        xhr.progress(deferred.notify);

        // Replace the ones already on the xhr obj
        deferred.promise(xhr);

        return xhr;
    },

    // $.ajaxUnobtrusive
    //
    // Uses the ajaxWithRedirect to provide a transparent interface by which we can make ajax
    // requests and have any returned html automatically ensure the elements are bound using jQuery validation.
    //
    // Otherwise the elements will not validate when they are added to the dom
    //
    // returns the xhr provided by the underlying $.ajax method
    ajaxUnobtrusive: function (options) {
        "use strict";

        // copy the actual success handler that has been defined
        var successHandler = options.success;

        options.success = function () {
            // forward to event success handler prior to validation to ensure
            // any html is loaded as necessary
            if (typeof successHandler === 'function') {
                successHandler.apply(null, [].slice.call(arguments, 0));

                // rebind the newly loaded validation elements       
                if (typeof options.dynamicValidation != 'undefined') {
                    // NOTE:  This method is defined in another file that extends validator
                    $.validator.unobtrusive.parseDynamicContent(options.dynamicValidation);
                }
            }
        };

        // fall through to our redirect ajax method
        return $.ajaxWithRedirect(options);
    },

    ajaxLoad: function (element$, options) {

        var successHandler;

        successHandler = options.success;

        options.success = function (html) {
            element$.html(html);

            if (typeof successHandler === 'function') {
                successHandler.apply(null, arguments);
            }
        };

        $.ajaxWithSpinner(options);
    },

    ajaxWithSpinner: function (options) {
        //var dataType = options.dataType || 'html';
        var containerid = options.spinnerOverlay || 'body';
        var overlayid = containerid.startsWith('.') || containerid.startsWith('#') ? containerid.substring(1) : containerid;

        //var type = options.type || 'GET';
        var completeHandler = options.complete;
        var successHandler = options.success;
        var beforeSendHandler = options.beforeSend;

        options.type = options.type || 'GET';
        options.dataType = options.dataType || 'html';
        options.traditional = options.traditional || false;
        options.success = function (data) {

            if (typeof successHandler === 'function') {
                successHandler.apply(null, arguments);
            }
        };

        options.complete = function () {
            if (containerid) {
                site.core.removeOverlay('#' + overlayid + '-spinner-overlay');
            }

            if (typeof completeHandler === 'function') {
                completeHandler.apply(null, arguments);
            }
        };

        options.beforeSend = function () {
            if (containerid) {
                site.core.addSpinnerOverlay($(containerid), overlayid + '-spinner-overlay');
            }

            if (typeof beforeSendHandler === 'function') {
                beforeSendHandler.beforeSend();
            }
        };

        return $.ajaxUnobtrusive(options);
    },

    ///
    /// jQuery plugin for stopping cross-site request forgery (CSRF)
    ///
    getAntiForgeryToken: function (tokenWindow, appPath) {
        // HtmlHelper.AntiForgeryToken() must be invoked to print the token.
        tokenWindow = tokenWindow && typeof tokenWindow === typeof window ? tokenWindow : window;

        appPath = appPath && typeof appPath === "string" ? "_" + appPath.toString() : "";
        // The name attribute is either __RequestVerificationToken,
        // or __RequestVerificationToken_{appPath}.
        var tokenName = "__RequestVerificationToken" + appPath;

        // Finds the <input type="hidden" name={tokenName} value="..." /> from the specified window.    
        var inputElements = tokenWindow.document.getElementsByTagName("input");

        for (var i = 0; i < inputElements.length; i++) {
            var inputElement = inputElements[i];
            if (inputElement.type === "hidden" && inputElement.name === tokenName) {
                return {
                    name: tokenName,
                    value: inputElement.value
                };
            }
        }

        return null;
    },

    appendAntiForgeryToken: function (settings) {

        // Gets token from current window by default.
        var token = $.getAntiForgeryToken();

        if (token) {
            var headers = {};
            headers[token.name] = token.value;
            settings.headers = headers;
        }

        return settings;
    },

    // Wraps $.post(url, data, callback, type) for most common scenarios.
    postAntiForgery: function (options) {
        return $.ajaxWithSpinner($.appendAntiForgeryToken(options));
    }

});

CSS

.spinner {
    background: url("../Images/waiting.gif") no-repeat center;
    bottom: 0;
    left: 0;
    margin: auto;
    position: absolute;
    right: 0;
    top: 0;
}
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.