MVC3: Redirect Action to HTML Bookmark

I'm currently in the process of writing my next personal project, RustyShark in .Net MVC3. It's a game and films review site, and will hopefully generate a bit of a community. Each review on the website has a comments form at the bottom that readers can use to post their own mini review, or just a simple comment. I'm using unobtrusive client side validation, but (as it should) the validation also occurs on the server side. This means that the user could potentially receive an error message on the comments form after a post-back but not notice it, as they will be redirected to the top of the page, rather than the comments form.

The "read review" page is accessed via the fairly standard route /Reviews/Read/{titleURL}, which means the reviews controller exposes the Read action method. The Read method has a single override which is tagged with the [HttpPost] attribute as follows:

    public class ReviewsController : Controller {

        public ActionResult Read(string titleURL) {
            // Get review by SEO friendly url
            var model = new ReadReviewModel();
            return View();
        }

        [HttpPost]
        public ActionResult Read(ReadReviewModel model, string titleURL) {
            if(ModelState.IsValid) {
                // Add user review comment
            }

            return View(model);
        }
    }

Fairly standard so far, yeah? Well, also pretty standard is applying a bookmark (e.g. /Reviews/Read/#comments) to a URL, which is especially useful when target content is located a good way down a page, such as in my example. MVC provides no built-in way to append a bookmark to your URL, and because bookmarks are not sent in the HTTP Header (browsers themselves implement the code to "scroll down" to a bookmark), its not possible to do this on the server side, unless I'm missing something fundamental.

Initially I went down the route of trying to modify the output route string on the fly, but I couldn't figure anything out. I'd be interested in if this is possible or not, though I don't imagine it would be easy or recommended.

So, after quite a bit of digging, I've managed to implement a fairly elegant solution (at least on the server side). I created an ActionFilter that modifies the HTTP buffer by adding a small amount of JavaScript just after the tag, after it's been rendered, but before the buffer is sent to the client. The ActionFilter is then applied it to the action method tagged with the HttpPost attribute (or any other action method, though I don't really see a good use for it anywhere else). The end result for the developer, is that you simply need to tag an additional attribute on to any action methods that you'd like to redirect to a bookmark, as follows:

    public class ReviewsController : Controller {

        public ActionResult Read(string titleURL) {
            // Get review by SEO friendly url
            var model = new ReadReviewModel();
            return View();
        }

        [HttpPost, AppendAnchor("commentForm")]
        public ActionResult Read(ReadReviewModel model, string titleURL) {
            if(ModelState.IsValid) {
                // Add user review comment
            }

            return View(model);
        }
    }

The AppendAnchorAttribute and its dependant classes are quite simple. First, the action filter:

using System;
using System.Web.Mvc;

namespace ShadowMoses.Core.Mvc.ActionFilters {
    /// <summary>
    /// Includes a small section of JavaScript in the response buffer that redirects the user to the desired page bookmark.
    /// </summary>
    [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
    public class AppendAnchorAttribute : ActionFilterAttribute {
        private readonly string _bookmark;

        /// <summary>
        /// Includes a small section of JavaScript in the response buffer that redirects the user to the desired page bookmark.
        /// </summary>
        /// <param name="bookmark">The bookmark to redirect to.</param>
        public AppendAnchorAttribute(string bookmark) {
            _bookmark = bookmark;
        }

        public override void OnResultExecuted(ResultExecutedContext filterContext) {
            var r = filterContext.HttpContext.Response;
            r.Filter = new IncludeAnchorScriptFilter(r.Filter) {
                Anchor = _bookmark
            };
            base.OnResultExecuted(filterContext);
        }
    }
}

The OnResultExecuted event stub takes the current HttpResponse (which has been rendered by this point) and applies a custom filter to it. The IncludeAnchorScriptFilter class inherits from Stream and does some simple string manipulation (made simple by a Regular Expression pattern):

using System.IO;
using System.Text;
using System.Text.RegularExpressions;

namespace ShadowMoses.Core.Mvc.ActionFilters {
    internal class IncludeAnchorScriptFilter : Stream {
        private static readonly Regex RegEx = new Regex("(<body([^>]*)>)", RegexOptions.Singleline);

        public string Anchor { get; set; }

        private readonly Stream _filter;

        public IncludeAnchorScriptFilter(Stream originalFilter) {
            _filter = originalFilter;
        }

        #region Overrides of Stream

        public override void Flush() {
            _filter.Flush();
        }

        public override long Seek(long offset, SeekOrigin origin) {
            return _filter.Seek(offset, origin);
        }

        public override void SetLength(long value) {
            _filter.SetLength(value);
        }

        public override void Write(byte[] buffer, int offset, int count) {
            var html = Encoding.UTF8.GetString(buffer, offset, count);
            html = RegEx.Replace(html, string.Concat("$1", string.Format(@"<script type=""text/javascript"">document.location = '#{0}';</script>", Anchor)), 1);

            buffer = Encoding.UTF8.GetBytes(html);

            _filter.Write(buffer, offset, buffer.Length);
        }

        public override bool CanRead {
            get {
                return _filter.CanRead;
            }
        }

        public override bool CanSeek {
            get {
                return _filter.CanSeek;
            }
        }

        public override bool CanWrite {
            get {
                return _filter.CanWrite;
            }
        }

        public override long Length {
            get {
                return _filter.Length;
            }
        }

        public override long Position {
            get {
                return _filter.Position;
            }
            set {
                _filter.Position = value;
            }
        }

        public override int Read(byte[] buffer, int offset, int count) {
            return _filter.Read(buffer, offset, count);
        }

        #endregion
    }
}

The Anchor property is populated when instantiating the filter in AppendAnchorAttribute. The RegEx pattern allows for multi-line tags with as many attributes as you'd like, unless an attribute value contains a right angle bracket. This minor issue can be rectified if you need it to be, but I have no use for that level of regular expression checking.

The Write() method reads the HTML buffer and inserts the specified JavaScript just after the HTML <body> tag in the response. The JavaScript is executed on page load, and simply instructs the browser to redirect itself to the bookmark. There is no HTTP POST or HTTP GET; the browser deals with the redirect on the client side, and because there is no post-back, the JavaScript only executes once.

The resulting URL will be /Reviews/Read/#commentForm, which will scroll down to my related anchor tag in my partial view:

<a id="commentForm"></a>

The "name" attribute of the anchor tag has been depreciated, and the id attribute seems to work just as well. I think you can also use the id attribute of an existing tag (such as <div id="commentForm"></div&gt>), though I haven't tested this yet.

Although I've tested this with Chrome, IE9, FireFox and Opera, I'm a little concerned that older browsers may not have implemented document.location in the same way over the years, and a post-back may occur.

It's not the cleanest solution to a problem I've come up with, but given the circumstances, I think it works quite well. Hope it helps people out :)

Popular posts from this blog

TDD and Unit Testing with Moq

Handling uploads with MVC4, JQuery, Plupload and CKEditor

Generating a self-signed SSL certificate for my QNAP NAS