Sunday, 18 April 2010

Progressive Enhancement with ASP.Net MVC

With its clean model for separation of concerns, one of the things I’ve been impressed by in use of MVC with ASP.Net is the support for building AJAX enabled applications that support progressive enhancement.

This technique takes the traditional strategy of graceful degradation in web design and turns it on its head. Rather than focussing on having the best possible user interface degrade to a less rich but still workable and presentable one, the focus is on creating a functional base UI that is enhanced by the addition of CSS and JavaScript to produce an improved user experience for those that can make use of it.

Of course either method can lead to the same result, but – in addition to the use of some specific techniques such as external referencing of assets to avoid swamping the more basic client with code or data that can’t be used – the change of approach leads to a different design mindset and result that better supports the full user base.

In this post I’ll illustrate a small part of an application I’ve recently worked on, where we developed an RSS reader for a site. One function was that users should be able to view a list of feeds, and add and remove RSS sources to it – and that we needed to support both script enabled clients and those that don’t have access to such technologies.

Firstly, the basic application before enhancement:

In the domain model I have a feed class that represents an RSS Feed object. Not shown in code but also developed for the application are repository and service methods for validation and the persistence of the list of feeds to the database.


    1 public class Feed


    2 {


    3     public int Id { get; set; }


    4     public string Name { get; set; }


    5     public string Url { get; set; }


    7     public List<FeedItem> Items { get; internal set; }


    8 }




In the controller code I have a few methods, for the retrieval of the list of feeds, and to support functions for the addition to and removal of them from the list. The key points to note here is that the addition of a new feed requires a straightforward form post, and the removal is via a link to a confirmation page, that in turn uses an HTTP post to proces the deletion. No client-side script required for a functional, if basic, UI.


    1 /// <summary>


    2 /// Render feeds page default view


    3 /// </summary>


    4 /// <returns></returns>       


    5 public ActionResult List()


    6 {


    7     IPrincipal user = GetUser();


    8 


    9     return View(new IndexViewModel


   10     {


   11         Feeds = feedsService.GetFeedList(user),


   12         IsLoggedIn = (user != null && user.Identity.IsAuthenticated),


   15     });


   16 }


   17 


   18 /// <summary>


   19 /// Receives the URL to an RSS feed, verifies it, and saves to the user's account


   20 /// </summary>


   21 /// <param name="url">URL of RSS feed</param>


   22 /// <returns></returns>


   23 [AcceptVerbs(HttpVerbs.Post)]


   24 public ActionResult Add(string url)


   25 {


   26     if (url.Length >= 4 && url.Substring(0, 4) != "http")


   27         url = "http://" + url;


   28 


   29     bool success = false;


   30     int feedId = 0;


   31     string feedName = "";


   32     string message = "Feed addition was unsuccesful. Please correct the errors and try again.";


   33     if (feedsService.ValidateRSSFeed(url))


   34     {


   35         Feed feed = new Feed();


   36         feedName = feedsService.GetRSSFeedNameFromUrl(url);


   37         feed.Name = feedName;


   38         feed.Url = url;


   40         try


   41         {


   42             feedId = feedsService.AddFeed(feed, ControllerContext.HttpContext.User);


   43             success = true;


   44             message = "The RSS feed has been added to your list.";


   45         }


   46         catch (RuleException ex)


   47         {


   48             ex.CopyToModelState(ModelState);


   49         }


   50     }


   51     else


   52     {


   53         ModelState.AddModelError("Url", "The URL does not point to a valid RSS feed.");


   54     }


   55 


   56     TempData["Message"] = message;


   57     return RedirectToAction("Index");


   58 }


   59 


   60 /// <summary>


   61 /// Renders view for remove confirmation


   62 /// </summary>


   63 /// <param name="id">Id of feed for removal</param>


   64 /// <returns></returns>       


   65 public ActionResult Remove(int id)


   66 {


   67     Feed feed = feedsService.GetFeed(id, GetUser());


   68     return View("Remove", feed);


   69 }


   70 


   71 /// <summary>


   72 /// Processes feed removal


   73 /// </summary>


   74 /// <param name="contact">Feed object (created by model binding)</param>


   75 /// <returns></returns>       


   76 [AcceptVerbs(HttpVerbs.Post)]


   77 public ActionResult Remove(Feed feed)


   78 {


   79     bool success = false;


   80     string message = "Feed removal was unsuccesful. Please correct the errors and try again.";


   81     try


   82     {


   83         feedsService.RemoveFeed(feed.Id, GetUser());


   84         success = true;


   85         message = "The RSS feed has been removed from your list.";


   86     }


   87     catch (RuleException ex)


   88     {


   89         ex.CopyToModelState(ModelState);


   90     }


   91 


   92     TempData["Message"] = message;


   93     return RedirectToAction("Index");


   94 }




My view code marks-up the HTML for the display of the list, the links for removal and the form for adding a new RSS feed to the list.


    1 <ul id="feed-list">


    2 


    3 <% foreach (var item in Model.Feeds) { %>


    4 


    5     <li>


    6         <span><%= Html.Encode(item.Id) %></span>


    7         <%= Html.ActionLink(item.Name, "Details", new { id = item.Id })%>


    8 


    9         <%if (Model.IsAdmin || !item.Required) { %>


   10             [<%= Html.ActionLink("Remove", "Remove", new { id = item.Id })%>]


   11         <% } %>


   12     </li>


   13 


   14 <% } %>


   15 


   16 </ul>


   17 


   18 <% if (Model.IsLoggedIn) { %>


   19 


   20     <% using (Html.BeginForm("Add", "Feeds", FormMethod.Post, new { id = "form-add-feed"}))


   21       {%>


   22 


   23         <fieldset>


   24             <legend>Add new feed to your list</legend>


   25             <p>


   26                 <label for="form-add-feed-url">Feed URL:</label>


   27                 <span>


   28                     <%= Html.TextBox("Url", "", new { id = "form-add-feed-url" })%>


   29                 </span>


   30             </p>


   31             <p>       


   32                 <input type="submit" value="Add Feed" />                   


   33             </p>


   34         </fieldset>     


   35 


   36     <% } %>


   37 


   38 <% } %>




So far very straightforward, but the interface requires a number of full page post backs to function, and hence for a better user experience we can progressively enhance the application with AJAX.

Firstly, for the addition of the feeds to the list we can hijack the form post using some jquery. The key point to note about this and the other progressive enhancement techniques described is that if the client doesn’t support them, they are simply ignored and the base functionality remains untouched.

Within this function we can read the URL from the form, and construct an HTTP post response from client side code. When the response comes back from the post, we can process it and display either a success message (and carry out additional processing such as adding the new feed to the HTML list) or error response as appropriate. Finally, and importantly, we return a false response such that the default form submission process is cancelled.


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


    2     hookUpAddFeedFormAjaxPost();


    3 });


    4 


    5 function hookUpAddFeedFormAjaxPost() {


    6     $("#form-add-feed").submit(function() {


    7 


    8         //Get feed details


    9         var url;


   10         url = $("#form-add-feed-url").val();


   11 


   12         //Post to controller action for adding feed


   13         $.post("/Feeds/Add/", { url: url },


   14             function(data) {


   15 


   16                 if (data.Success) {


   17 


   18                     //Success... show message


   19                     $("#ajax-message p.message").text(data.Message);


   20                     $("#ajax-message").show();


   21 


   22                     //Add feed to list (if not already there)


   23                     var id, name;


   24                     name = data.Data.split("|")[0];


   25                     id = data.Data.split("|")[1];


   26 


   27                     if ($("#feed-list li span:contains(\"" + name + "\")").length == 0) {


   28                         $("#feed-list").append("<li><a href=\"/Feeds/View/" + id +


   29                             "\" class=\"feed\">" + name +


   30                             "</a> [<a href=\"/Feeds/Remove/" + id +


   31                             "\">Remove</a>]</li>");


   34                     }


   35 


   36                     $("#form-add-feed-url").val("");


   37 


   38                 } else {


   39 


   40                     //Failed... show errors


   41                     $("#ajax-error span.validation-summary-errors").text(data.Message);


   42                     for (var i = 0; i < data.Errors.length; i++)


   43                         $("#ajax-error ul").append("<li>" + data.Errors[i] + "</li>");


   44                     $("#ajax-error").show();


   45                 }


   46 


   47             }, "json");


   48 


   49         return false;


   50     });


   51 }




In order to hook all this up we need to make some changes to our controller code, but actually very little - which is where the support of this approach from the ASP.Net MVC framework comes in. In the controller we can examine a property of the Request object to determine if the action method is being called from an AJAX request, and take appropriate steps. Here we want to by-pass the usual RedirectToAction at the end of the method, and instead format and return a JSON response.


   56     if (Request.IsAjaxRequest())


   57         return CreateAjaxResponse(success, message, ModelState, feedName + "|" + feedId);


   58     else


   59     {


   60         TempData["Message"] = message;


   61         return RedirectToAction("Index");


   62     }




The JSON response itself is contructed using this helper function:


    1 /// <summary>


    2 /// Private helper method to format a JSON respone for AJAX requests


    3 /// </summary>


    4 /// <param name="success">Flag indicating success or failure of the method</param>


    5 /// <param name="message">Message</param>


    6 /// <param name="modelState">ModelStateDictionary containing error details on failure</param>


    7 /// <param name="data">Any further specific information required by callback</param>


    8 /// <returns></returns>


    9 private JsonResult CreateAjaxResponse(bool success, string message,


   10     ModelStateDictionary modelState, string data)


   11 {


   12     JsonResult json = new JsonResult();


   13     IList<string> errors = new List<string>();


   14     foreach (var item in modelState.Values)


   15         foreach (ModelError me in item.Errors)


   16             errors.Add(me.ErrorMessage);


   17     json.Data = new


   18     {


   19         Success = success,


   20         Message = message,


   21         Errors = errors,


   22         Data = data


   23     };


   24     return json;


   25 }




In a similar manner we can intercept the click of the remove link to disable the normal function and instead provide a client side method of confirming the deletion and constructing the HTTP post to process it. Again, if scripting is not supported, the hyperlink will just function as a normal anchor link to the confirmation form. But if it is, our client-side code will be executed and the rich AJAX UI provided instead.

No comments:

Post a Comment