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";
                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();
                            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("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 + ");");

            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;

No comments:

Post a Comment