Adaptive Rendering in ASP.NET MVC

ASP.NET MVC comes with different action results for various purpose, but some times, you will find those are not adequate for your scenario, lets consider the following screenshot:

Dashboard

When user navigates to a different tab or change the sort order or moves to a different page number, it will load the content as an ajax call, but if the user has JavaScript turned off, it will redirect to a regular url. One solution would be to create separate sets of controller actions that will return either the complete view (in case JavaScript is turned off) or the partial view and use the jQuery load to show the content. But the problem of this approach is that we will be duplicating the same logic for both the actions, moreover we will be returning the unnecessary html tags rather than pure json object that is sufficient for rendering the view. A better solution would be adaptive rendering. If you are not familiar with adaptive rendering then let me clarify it a bit, it is a process where the server responds differently depending upon the browser capability. So it has a broader scope(e.g. mobile devices, text only browsers etc) comparing  to our above JavaScript on/off scenario. But for the time being let us only focus on the above, so instead of creating pure html/ajax only version we would like to take the advantage of the browser capability and for this we will create a new action result, the beauty of the new action result is, the controller remains completely unaware of what kind of request it is serving. Lets take a look of the controller action that is serving both:

public ActionResult Dashboard(string userName, StoryListTab tab, OrderBy orderBy, int? page)
{
    StoryListViewModel viewModel = new StoryListViewModel
                                       {
                                           SelectedTab = tab,
                                           CurrentPage = page ?? 1,
                                           StoryPerPage = _storyPerPage,
                                           SelectedOrderBy = orderBy,
                                           UrlFormat = ((Route) RouteTable.Routes["Dashboard"]).Url
                                       };

    int start = PageCalculator.StartIndex(page, _storyPerPage);

    PagedResult<IStory> pagedResult = _dashBoardMethods[tab](_storyService, userName, start, _storyPerPage, orderBy);

    viewModel.TotalStoryCount = pagedResult.Total;
    viewModel.Stories = pagedResult.Result;

    return AdaptiveView(viewModel);
}

Check that we are not returning the ViewResult, instead we are returning a new action result AdaptiveViewResult. This action result is intelligent enough to decide what to return depending upon the request.

public class AdaptiveViewResult : ViewResult
{
    private readonly IEnumerable<JavaScriptConverter> _converters;

    public AdaptiveViewResult() : this(ServiceLocator.Current.GetAllInstances<JavaScriptConverter>())
    {
    }

    public AdaptiveViewResult(IEnumerable<JavaScriptConverter> converters)
    {
        _converters = converters;
    }

    public override void ExecuteResult(ControllerContext context)
    {
        if (context.HttpContext.Request.IsAjaxRequest())
        {
            ExecuteAjaxView(context);
        }
        else
        {
            ExecuteHtmlView(context);
        }
    }

    private void ExecuteAjaxView(ControllerContext context)
    {
        JavaScriptSerializer jsonSerializer = CreateJsonSerializer();

        //We can't serialize the modelState as it has deep nesting with objects that are not serializable.
        //So we need to convert it to serializable object.
        var modelStates = ViewData.ModelState.IsValid ?
                          null :
                          ViewData.ModelState.Select(ms => new
                                                                {
                                                                    ms.Key,
                                                                    Errors = ms.Value.Errors
                                                                                     .Select(error => (error.Exception == null) ? error.ErrorMessage : error.Exception.Message)
                                                                                     .Where(error => !string.IsNullOrEmpty(error))
                                                                                     .AsEnumerable()
                                                                }
                                                    ).Where(ms => ms.Errors.Count() > 0) //No need to include items that does not have errors
                                                    .ToList();

        var result = new
                        {
                            model = ViewData.Model,
                            modelStates
                        };

        string json = jsonSerializer.Serialize(result);

        HttpResponseBase response = context.HttpContext.Response;

        response.ContentType = "application/json";
        response.Write(json);
    }

    private void ExecuteHtmlView(ControllerContext context)
    {
        base.ExecuteResult(context);
    }

    private JavaScriptSerializer CreateJsonSerializer()
    {
        JavaScriptSerializer serializer = new JavaScriptSerializer();

        serializer.RegisterConverters(_converters);

        return serializer;
    }
}

The AdaptiveViewResult is inheriting from the regular ViewResult but when returning the result it will return based upon the type of request and when serializing, it serializes both the model and modelStates. Here is the client side code that updates content with the json result.

//Remove with # as we can do ajax request
$('#storyListTabs > ul.ui-tabs-nav > li > a').attr('href', '#')
$('#storyListTabs > ul.ui-tabs-nav > li').click(
                                                    function()
                                                    {
                                                        Story._selectTab($(this));
                                                    }
                                                );

_selectTab: function(target)
{
    var selectedTab = target.children('a').text();

    //No need to proceed further when clicking the same tab
    if ((Story._currentPage === 1) && (Story._currentTab === selectedTab))
    {
        return;
    }

    Story._switchTab(selectedTab);
},

_switchTab: function(selectedTab)
{
    Story._load(
                    selectedTab,
                    1,
                    Story._currentOrderBy,
                    function()
                    {
                        Story._currentTab = selectedTab;
                        //Reset to first page
                        Story._currentPage = 1;

                        $('#storyListTabs > ul.ui-tabs-nav > li').removeClass('ui-tabs-selected ui-state-active')
                                                                 .find('a').each(
                                                                                    function()
                                                                                    {
                                                                                        var a = $(this);

                                                                                        if (a.text() == selectedTab)
                                                                                        {
                                                                                            a.parent('li').addClass('ui-tabs-selected ui-state-active');
                                                                                        }
                                                                                    }
                                                                                );
                    }
                );
},

_load: function(tab, page, orderBy, callback)
{
    var url = Story._buildUrl(tab, page, orderBy);

    $U.blockUI($('#storyListTabs'));

    $.ajax(
                {
                    url: url,
                    type: 'GET',
                    dataType: 'json',
                    success: function(result)
                    {
                        $U.unblockUI();
                        Story._pageCount = result.model.pageCount;
                        Story._show(result.model);
                        callback();
                        Story._updateState();
                    },
                    error: function(e)
                    {
                        $U.unblockUI();
                    }
                }
            );
},

_show: function(model)
{
    var list = $('#list');

    if (model.stories.length > 0)
    {
        var content = '';

        for (var i = 0; i < model.stories.length; i++)
        {
            content += Story._createStoryHtml(model.stories[i]);
        }

        $('#sortBar').show();
        $('#notify').hide();
        list.empty().html(content);
        $('#actionBar').show();
    }
    else
    {
        $('#sortBar').hide();
        $('#notify').show();
        list.empty();
        $('#actionBar').hide();
    }

    if (model.pageCount > 1)
    {
        $('#pager').show();
    }
    else
    {
        $('#pager').hide();
    }
},

I have excluded other parts of the JavaScripts and yes you have to write some complex jQuery codes to achieve the smooth user experience and there is no alternate to it.

In the above AdaptiveViewResult we are also serializing the ModelStates, but it was not used, now lets take another example where it is used, the following is a typical signup form:

Signup

The validation are regular rules like all the fields are required, password and user name should not contain any special character and user name and email should be unique. We can use the jQuery validation plug-in to validate in the client side, but things getting complex when we want to ensure the unique user name and email, certainly we can use the jQuery validation remote, but it does not guarantee the uniqueness of those fields on the actual creation, moreover we would also like to ajaxify the form with the jQuery form plug-in. Now, lets first check the action method of the controller:

[AcceptVerbs(HttpVerbs.Post)]
public ActionResult SignUp(string userName, string password, string confirmPassword, string email)
{
    if (ValidateSignUp(userName, password, confirmPassword, email))
    {
        try
        {
            IUser user = _membershipService.Register(userName, password, email);
            _formsAuthentication.SetAuthticationCookie(user.UserName, false);

            return AdaptiveRedirect(Url.Dashboard());
        }
        catch (ReadZException e)
        {
            //Throws when User Name or Email is not unique
            ModelState.AddModelError(ModelStateException, e.Message);
        }
    }

    return AdaptiveView();
}

As you can see we are returning the same AdaptiveViewResult when the validation fails or when the user name and email is not unique, but when everything is okay we are returning another new action result AdaptiveRedirectResult rather than the regular RedirectResult. When using the jQuery forms plug-in there is no way to get the underlying XmlHttpRequest object in the JavaScript, so we cannot get the header which is set by the regular RedirectResult. The AdaptiveRedirectResult is inherited from the regular RedirectResult but for ajax request, it serializes the url rather than redirecting, so in the client side we can easily get the redirect url from the response.

public class AdaptiveRedirectResult : RedirectResult
{
    public AdaptiveRedirectResult(string url) : base(url)
    {
    }

    public override void ExecuteResult(ControllerContext context)
    {
        if (context.HttpContext.Request.IsAjaxRequest())
        {
            AjaxRedirect(context);
        }
        else
        {
            base.ExecuteResult(context);
        }
    }

    private void AjaxRedirect(ControllerContext context)
    {
        JsonResult result = new JsonResult { Data = new { redirectUrl = Url } };

        result.ExecuteResult(context);
    }
}

Now let take a look at the client side jQuery code:

$.validator.addMethod(
                        'userName',
                        function(value, element)
                        {
                            return /^([a-zA-Z])[a-zA-Z_-]*[\w_-]*[\S]$|^([a-zA-Z])[0-9_-]*[\S]$|^[a-zA-Z]*[\S]$/.test(value);
                        },
                        'User name must be alphanumeric characters which starts with alphabet and can only contains special characters dash and underscore.'
                    );

$('#signUp').validate(
                            {
                                rules:
                                        {
                                            userName:
                                                                {
                                                                    required: true,
                                                                    minlength: Membership._minUserNameLength,
                                                                    userName: true
                                                                },
                                            password:
                                                                {
                                                                    required: true,
                                                                    minlength: Membership._minPasswordLength
                                                                },
                                            confirmPassword:
                                                                {
                                                                    required: true,
                                                                    minlength: Membership._minPasswordLength,
                                                                    equalTo: '#password'
                                                                },
                                            email:
                                                              {
                                                                  required: true,
                                                                  email: true
                                                              }
                                        },
                                messages:
                                            {
                                                userName:
                                                            {
                                                                required: 'User name cannot be blank.',
                                                                minlength: 'User name cannot be less than ' + Membership._minUserNameLength + ' characters.'
                                                            },
                                                password:
                                                            {
                                                                required: 'Password cannot be blank.',
                                                                minlength: 'Password cannot be less than ' + Membership._minPasswordLength + ' characters.'
                                                            },
                                                confirmPassword:
                                                                    {
                                                                        required: 'Confirm password cannot be blank.',
                                                                        minlength: 'Confirm password cannot be less than ' + Membership._minPasswordLength + ' character.',
                                                                        equalTo: 'Confirm password does not match with password.'
                                                                    },
                                                email:
                                                          {
                                                              required: 'Email cannot be blank.',
                                                              email: 'Email address format is not correct.'
                                                          }
                                            },
                                submitHandler: function(form)
                                {
                                    var options = {
                                                        dataType: 'json',
                                                        beforeSubmit: function()
                                                        {
                                                            $U.disableInputs($(form), true);
                                                        },
                                                        success: function(result)
                                                        {
                                                            $U.disableInputs($(form), false);

                                                            if (result.redirectUrl)
                                                            {
                                                                window.location.href = result.redirectUrl;
                                                            }
                                                            else
                                                            {
                                                                $U.mapErrors(result.modelStates);
                                                                $U.focus($('#userName'));
                                                            }
                                                        }
                                                };

                                    $(form).ajaxSubmit(options);
                                    return false;
                                },
                                errorContainer: 'div.validationErrors',
                                errorLabelContainer: 'div.validationErrors ul',
                                wrapper: 'li',
                                highlight: onHighlight,
                                unhighlight: onUnhighlight
                            }
                        );

mapErrors: function(modelStates, selector)
{
    var summary = selector ? $(selector) : $('div.validationErrors');

    var list = summary.children('ul');
    list.empty();

    for (var i = 0; i < modelStates.length; i++)
    {
        var e = $('#' + modelStates[i].Key);

        e.addClass('input-validation-error');
        var span = e.next('span.error');
        span.addClass('error').text('*').show();

        for (var j = 0; j < modelStates[i].Errors.length; j++)
        {
            list.append('<li><label class=\"error\" for=\"' + modelStates[i].Key + '\" generated=\"true\">' + modelStates[i].Errors[j] + '</label></li>');
        }
    }

    list.show();
    summary.show();
},

As you can see (line 73- 80) when the form post is successful it redirects to the url that the server is returning in json and when the post fails it shows the server side errors like user name or email is not unique.

That’s it.

In this post I have shown how you can implement adaptive rendering in ASP.NET MVC thus creating rich user experience. And of course it requires bit of work and it is not as painless as ASP.NET AJAX UpdatePanel. But it will give the complete control over the data that is transmitting and more control the way we are interacting with the user.

There is no attached source codes in this post, I am still working on it and this should be the reference implmentation of my ASP.NET MVC Best Practice.

Shout it

2 Comments

  • Thanks for the awesome blog posts!! How are you formatting your javascript files? Is that manual or have you got visual studio doing that? Also would you be able to shead some light on how you minify your js files in KIGG?

  • Glad that you find it useful.

    Yes, I basically turned off all the formatting checkbox of JS in VS.

    The KiGG js files were minified & obfuscated with yahoo compresssor from the command line, like:

    java -jar yuicompressor-2.3.6.jar "../Web/Assets/Javascripts/Administration.js" -o "../Web/Assets/Javascripts/Administration.min.js" --type js --charset utf-8

Comments have been disabled for this content.