Wednesday 11 April 2012

A DRY Approach to using Fancybox in an MVC Project

Typically, when an MVC View is opened inside a fancybox you need to display a page that is somewhat simpler than the other Views on your site.  So, while you might have a standard layout file that you use when creating Views, which has a standard header and footer, for example, you have to make the up-front decision that the Views to be rendered inside light boxes will use a different layout with less decoration (after all, you don't want your header and footer repeating inside the light box).


So, how do you handle the inevitability that someone out there using your site will be super-paranoid about security and has decided to switch JavaScript off?  For him, that fancybox link will still navigate the browser to the Controller method in question, but as you're probably returning a PartialView  _ViewStart.cshtml won't be called and your user will experience a fairly bland page that looks out of place compared with everything else on your site.  And, what's worse, if you haven't developed with that user in mind and have coded the View to only work inside a light box, dependent on the user's ability to close the light box manually, or making the navigation within the View entirely dependent on JavaScript, the user is now stuck and can only get back to the rest of your site by using the browser's Back button.


My solution to the problem is to use progressive enhancement.  Only enable things that rely on JavaScript using JavaScript itself, and have something meaningful behind the default (non-JavaScript) behaviour.  In the past my solutions was something along these lines:

  1. Create a PartialView that renders the content I want to display inside the fancybox.
  2. Create a full View that simply wraps my PartialView with a proper Layout.
  3. Create two Controller methods, one that returns my full ViewResult and another that returns the PartialView result.
    • It's important to name these methods something like "ShowMyView" and "ShowMyPartialView"
  4. Render an ActionLink that, by default navigates to the ControllerMethod that returns the full ViewResult.
  5. In JavaScript write some client code that transforms the href on the ActionLink at document load from "ShowMyView" to "ShowMyPartialView" and attaches the fancybox.
As you can imagine, I soon got a little tired with all this messing about just to accommodate that 1 in a million user who either has turned off JavaScript or is somehow still using Netscape Navigator.

So, my solution was to encapsulate all of that logic in the following HtmlHelper extension method and ActionFilterAttribute.


In brief, the HtmlHelper extension method allows you to create a link that will have the fancybox JavaScript applied to it at document load (the method creates that script so that you don't have to, although you will need to include jQuery in your project and probably have it referenced in the head on your Layout View).  


You won't need to create a separate PartialView and View any more, just create the View as you would if it was to be rendered as a normal page on your site; nor will you need to code two Controller methods - just the one.  That's because the FancyboxActionAttribute that you'll need to use to decorate your one any only Controller method, will look into the request to see if the request was originated by a fancybox link and use an alternative layout file (provided in the attribute constructor as a path).  So, all you need to ensure is that you have an alternative Layout file that renders a minimal view inside a fancybox.  If the attribute doesn't detect that the request came from a fancybox link it will just return the View in its standard format.

public static class HtmlHelperExtensions
    {        
        public static MvcHtmlString FancyboxLink(this HtmlHelper htmlHelper, string linkText, string actionName, string controllerName, object routeValues = null, object htmlAttributes = null, object fancyboxOptions = null)
        {
            MvcHtmlString actionLink = htmlHelper.ActionLink(linkText, actionName, controllerName, routeValues, htmlAttributes);

            string link = actionLink.ToString();
            string href = "", preHref = "", postHref = "", script = "", fancyboxOptionsString = "";
            Guid id = Guid.NewGuid();
            int locationQuestionMark = link.IndexOf("?");
            int locationHref = link.ToLower().IndexOf("href=\"");
            int locationEndHref = link.IndexOf("\"", locationHref + "href=\"".Length);

            preHref = link.Substring(0, locationHref);
            preHref += " data-fancybox=\"" + id.ToString() + "\"";
            postHref = link.Substring(locationEndHref);
            href = link.Substring(locationHref, locationEndHref - locationHref);

            if (locationQuestionMark > 0)
            {
                href += "&fancybox=false";
            }
            else
            {
                href += "?fancybox=false";
            }

            link = preHref + " " + href + " " + postHref;

            StringBuilder fancyboxOptionsStringBuilder = new StringBuilder();
            if (fancyboxOptions != null)
            {
                PropertyInfo[] members = fancyboxOptions.GetType().GetProperties();
                for (int i = 0; i < members.Length; i++)
                {
                    PropertyInfo prop = members[i];

                    string propName = prop.Name;
                    object propValue = prop.GetValue(fancyboxOptions, null);
                    string propValueString = "";

                    if (propValue is int || propValue is bool)
                    {
                        propValueString = propValue.ToString().ToLower();
                    }
                    else if (propValue is string)
                    {
                        if (propValue.ToString().ToLower().StartsWith("function"))
                        {
                            propValueString = propValue.ToString();
                        }
                        else
                        {
                            propValueString = "'" + propValue.ToString() + "'";
                        }
                    }

                    if (i != members.Length - 1)
                    {
                        propValueString += ",";
                    }

                    fancyboxOptionsStringBuilder.AppendLine(propName + ":" + propValueString);
                }
            }

            fancyboxOptionsString = "{" + fancyboxOptionsStringBuilder.ToString() + "}";

            StringBuilder scriptBuilder = new StringBuilder();
            scriptBuilder.AppendLine("<script language=\"javascript\">");
            scriptBuilder.AppendLine("$(function(){");
            scriptBuilder.AppendLine("var href = $('a[data-fancybox=\"" + id.ToString() + "\"]').attr('href');");
            scriptBuilder.AppendLine("href = href.replace('fancybox=false', 'fancybox=true');");
            scriptBuilder.AppendLine("$('a[data-fancybox=\"" + id.ToString() + "\"]').attr('href', href);");
            scriptBuilder.AppendLine("$('a[data-fancybox=\"" + id.ToString() + "\"]').fancybox(" + fancyboxOptionsString + ");");
            scriptBuilder.AppendLine("});");
            scriptBuilder.AppendLine("</script>");

            script = scriptBuilder.ToString();

            return new MvcHtmlString(link + script);
        }
    }

   
  
    [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
    public class FancyboxActionAttribute : ActionFilterAttribute
    {
        public string LayoutPath { get; set; }

        /// <summary>
        /// 
        /// </summary>
        /// <param name="LayoutPath">The path to the alternative Layout file to use
        /// for the view when rendered within the lightbox
        /// </param>
        public FancyboxActionAttribute(string LayoutPath)
        {
            this.LayoutPath = LayoutPath;
        }

        public override void OnResultExecuting(ResultExecutingContext filterContext)
        {
            if (filterContext.RequestContext.HttpContext.Request["fancybox"] != null)
            {
                string returnInFancyBoxString = filterContext.RequestContext.HttpContext.Request["fancybox"].ToString();
                bool returnInFancyBox = bool.Parse(returnInFancyBoxString);

                ActionResult result = filterContext.Result;
                if (result is ViewResult && returnInFancyBox)
                {
                    ViewResult viewResult = result as ViewResult;
                    viewResult.MasterName = LayoutPath;
                }
            }
        }
    }

Wednesday 28 March 2012

Extending the MVC3 RemoteAttribute to validate server-side when JavaScript is disabled

In ASP.NET MVC3 the RemoteAttribute class works seamlessly with unobtrusive JavaScript to asynchronously execute server side validation via AJAX.

However, one of the problems with this approach arises when JavaScript is disabled on the user's browser.  A thorough approach to validation will not rely on JavaScript being enabled, but the traditional DRY approach  suggested by many is simply to encapsulate your validation logic in a method and call this from both your RemoteAttribute controller action and also on your page submission code as a backup, calling ModelState.AddModelError() if the validation fails, passing the model back to the view to signal that validation failed.  Sadly, this renders the separation of concerns within MVC validation redundant as the controller method is now responsible for performing validation as well as its intended business logic.

A simple approach to fixing this error is to extend the existing RemoteAttribute class, overriding the IsValid method to call your remote controller method from the server.  The code sample below works well for controller methods that return a JsonResult containing a boolean value.

[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class RemoteWithServerSideAttribute : RemoteAttribute
{
    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        string controllerName = this.RouteData["controller"].ToString();
        string actionName = this.RouteData["action"].ToString();
        string[] additionalFields = this.AdditionalFields.Split(',');

        List<object> propValues = new List<object>();
        propValues.Add(value);
        foreach (string additionalField in additionalFields)
        {
            PropertyInfo prop = validationContext.ObjectType.GetProperty(additionalField);
            if (prop != null)
            {
                object propValue = prop.GetValue(validationContext.ObjectInstance, null);
                propValues.Add(propValue); 
            }
        }

        Type controllerType = Assembly.GetExecutingAssembly().GetTypes().FirstOrDefault(t => t.Name.ToLower() == (controllerName + "Controller").ToLower());
        if (controllerType != null)
        {
            object instance = Activator.CreateInstance(controllerType);

            MethodInfo method = controllerType.GetMethod(actionName);

            if (method != null)
            {
                ActionResult response = (ActionResult)method.Invoke(instance, propValues.ToArray());

                ValidationResult output = null;

                if (response is JsonResult)
                {
                    bool isAvailable = false;
                    JsonResult json = (JsonResult)response;
                    string jsonData = json.Data.ToString();

                    bool.TryParse(jsonData, out isAvailable);

                    if (!isAvailable)
                    {
                        return new ValidationResult(this.FormatErrorMessage(validationContext.DisplayName));
                    }
                    else
                    {
                        return null;
                    }
                }
            }
        }

        return null;
    }    

    public RemoteWithServerSideAttribute(string routeName)
        : base()
    {
    }

    public RemoteWithServerSideAttribute(string action, string controller)
        : base(action, controller)
    {
    }

    public RemoteWithServerSideAttribute(string action, string controller, string areaName)
        : base(action, controller, areaName)
    {
    }

}