Friday, 8 March 2013

Localised Form Validation in ASP.Net MVC

I've recently been working on a web application that required localisation - the display of translated copy depending on which country the user was visiting from. There was a different sub-domain for each country which was used to identify the appropriate one.

It was also a requirement that the editor's of the website could update the translated copy from a simple back-office tool.

For most of the copy of the website this was fairly straightforward to set up. I had a database table called Texts that contained three columns: Country, Key and Value. And hence by looking up for a particular country and key I could get the text to display.

To ensure this was performant in practice what I did for each web request was to look up all the keys and values for the appropriate market and populate a dictionary object. The look-up was cached using so I was only hitting the database when I needed to rather than on every request.

I then created a base view model that all other view models were derived from that looked like this:

    public abstract class BaseViewModel
    {
        public BaseViewModel(IList<Text> dictionary)
        {
            Dictionary = dictionary;
        }

        public IList<Text> Dictionary { get; set; }

        public string GetText(string key)
        {
            if (Dictionary != null && !string.IsNullOrEmpty(key))
            {
                var text = Dictionary.Where(x => x.Key == key).SingleOrDefault();
                if (text != null)
                {
                    return text.Value;
                }
            }

            return string.Empty;
        }
    }

With this in place, rendering the appropriate text from the view was a simple matter of calling the GetText method and passing the required key:

    @Model.GetText("copyright")

All very straightforward so far, but the piece that needed a bit more thought was how to handle form validation messages, supporting both client and server side methods. There's a fairly well established method using resource files, but in my case that wasn't ideal as I wanted to support the editor's making amends to the texts, and hence needed to have them in the database rather than baked into a resource.

First step was to apply the usual data annotations, but rather than hard coding the error messages I instead used the keys from my translations table, e.g.

    [Required(ErrorMessage = "contact_validation_first_name_required")]
    [StringLength(20, ErrorMessage = "contact_validation_first_name_length")]
    public string FirstName { get; set; }

In my view I set up the field as follows:

    
    @Html.EditorFor(m => m.FirstName)
    @Html.LocalizedValidationMessageFor(m => m.FirstName, Model.Dictionary)

LocalizedValidationMessageFor was a custom helper used to render the appropriate error message for the country, and was implemented as an extension method on the HtmlHelper:

    public static class LocalizationHelpers
    {
        public static MvcHtmlString LocalizedValidationMessageFor<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper, Expression<Func<TModel, TProperty>> expression, IList<Text> dictionary)
        {
            // Get HTML returned from base ValidationMessageFor method
            var html = ValidationExtensions.ValidationMessageFor(htmlHelper, expression);

            // Check we are looking at one that is flagging a validation error
            if (html != null && dictionary != null && html.ToString().Contains("field-validation-error"))
            {

                // Get the key from the HTML (it contains a single span tag)
                var key = html.Substring(html.IndexOf(">") + 1).Replace("</span>", string.Empty);

  // Look up the text value based on the key from the passed dictionary
                var text = dictionary.Where(x => x.Key == key).SingleOrDefault();
                if (text != null)
                {

          // Replace the key with the translation
                    var amendedHtml = html.ToString().Replace(key, text.Value);
                    return MvcHtmlString.Create(amendedHtml);
                }
            }

            return html;            
        }
    }

That worked nicely for server side validation messages, but the client side ones would still display the key rather than the translated text. To handle this scenario I added a further method the BaseViewModel which would return the part of the dictionary of key/value pairs as a JSON result:

    public string GetDictionaryAsJson(string stem = "")
    {
        if (Dictionary != null)
        {
            var serializer = new JavaScriptSerializer();
            return serializer.Serialize(Dictionary
                .Where(x => string.IsNullOrEmpty(stem) || x.Key.StartsWith(stem))
                .Select(x => new { Key = x.Key, Value = x.Value }));
        }

        return string.Empty;
    }

From within the view, I made a call to a javascript function, passing in this dictionary in JSON format:

    localizeClientSideValidationMessages(@Html.Raw(Model.GetDictionaryAsJson("contact_validation")));

That function looked like the following, where for each tye of validation I was using (required, string length etc.) the key that was currently rendered was replaced with the appropriate translated text retrieved from the dictionary:

    function localizeClientSideValidationMessages(dictionary) {

        // Convert to JSON arry
        dictionary = eval(dictionary);

        // Localize fields (need to call for each type of validation)
        localizeFieldValidation(dictionary, "data-val-required");
        localizeFieldValidation(dictionary, "data-val-length");
        localizeFieldValidation(dictionary, "data-val-regex");
        localizeFieldValidation(dictionary, "data-val-equalto");

        // Reparse form (necessary to ensure updates)
        $("form").removeData("validator");
        $("form").removeData("unobtrusiveValidation");
        $.validator.unobtrusive.parse("form");

    }

    function localizeFieldValidation(dictionary, validationAttribute) {
       
        // For each form element with validation attribute, replace the key with the translated text
        $("input[" + validationAttribute + "],select[" + validationAttribute + "],textarea[" + validationAttribute + "]").each(function (index) {
            $(this).attr(validationAttribute, getLocalizedValue(dictionary, $(this).attr(validationAttribute)));
        });

    }

    function getLocalizedValue(dictionary, key) {

 // Look up the value for the passed key
        for (var item in dictionary) {
            if (dictionary.hasOwnProperty(item)) {
                if (dictionary[item].Key == key) {
                    return dictionary[item].Value;
                }
            }
        }

        return "";

    }

No comments:

Post a Comment