Monday, January 10, 2011

Localization in ASP.NET MVC2 with Data Annotations

Let’s start by getting everyone on the same page.  Create a new Project | C# | Web | ASP.NET MVC 2 Web Application using the .NET Framework 4. With or without a test project, it’s up to you.  In this example we will setting up both English and French translation.  Adding more languages should be straight forward.

Setting up the Resource folder structure.

image

 

At the root of your MVC application create a Resources folder.  Inside this folder create a folder structure like the one to the left.

I like to try and keep all the resources organized so under the Models and Views folders I like to break things apart by controller.  Ultimately how you choose to store these items is up to you.  Changes to the folder structure will affect the namespaces.

Adding resource files and what you need to know.
Adding a resource file is the easy part.  Making the setting changes that are needed turns out to be a repetitive process that will get old real fast.  Right click on the Views | Home folder and select Add | New Item.  In the Add new item dialog type the word resource into the search installed templates textbox in the upper right hand corner. Select the Resource File template and type Index.resx.
image

Once the file has been added it should open up in edit mode.  Make the following changes:
image

Accessing the resource details is pretty straight forward after this part.  For example you could type the fully qualified name of a resource property like “applicationname.Resources.Views.Home.Index.PageTitle”. 

image

That namespace tends to be a little long so add the following to the properties of the resource file.

Add a custom tool namespace to the resource file “ViewRes.Home”.  You will then be able to access the values like “ViewRes.Home.Index.PageTitle”.

This process turns out to be one of the repetitive processes I was referencing above.  In order to limit the namespace length this modification must be made on each file for each language although its not required.

Also note that the second part of the entered namespace will need to change depending on what folder you are in for example you may change the namespace to “ViewRes.Account”.

Let’s go ahead and add the French file.  Do the same as above but make sure the name of the resource file is Index.fr.resx.  Don’t forget to change the Custom Tool Namespace value in the properties of the resource file. Since this would be the French file make sure the values in the resource file are French.  I like to use Google translate (http://translate.google.com/) although I strongly recommend having this translation done professionally.  Here is the translation:

Message = Bienvenue sur ASP.NET MVC! Exemple de localisation
PageTitle = Page d'accueil avec une localisation

Note I use the following naming structure for my resource files:  {page}.{culture}.resx.  The culture value can be the short or long version of the culture string for example all of these would work.

  • Index.fr.resx
  • Index.en-fr.resx
  • etc…

If you do not include a culture in the file like we did with Index.resx then it will be considered the default.

Enabling culture switching.
Now paste the following code into your global.asax.cs file.

protected void Application_AcquireRequestState(object sender, EventArgs e)
{
    string cultureCode = SetCurrentLanguage(HttpContext.Current);

    if (string.IsNullOrEmpty(cultureCode)) return;

    HttpContext.Current.Response.Cookies.Add(
        new HttpCookie("Culture", cultureCode)
        {
            HttpOnly = true,
            Expires = DateTime.Now.AddYears(100)
        }
    );

    CultureInfo culture = new CultureInfo(cultureCode);
    System.Threading.Thread.CurrentThread.CurrentCulture = culture;
    System.Threading.Thread.CurrentThread.CurrentUICulture = culture;
}

private static string GetCookieCulture(HttpContext context, ICollection<string> cultures)
{
    /* Get the language in the cookie*/
    HttpCookie userCookie = context.Request.Cookies["Culture"];

    if (userCookie != null)
    {
        if (!string.IsNullOrEmpty(userCookie.Value))
        {
            if (cultures.Contains(userCookie.Value))
            {
                return userCookie.Value;
            }
            return string.Empty;
        }
        return string.Empty;
    }
    return string.Empty;
}

private static string GetSessionCulture(HttpContext context, ICollection<string> cultures)
{
    if (context.Session["Culture"] != null)
    {
        string SessionCulture = context.Session["Culture"].ToString();

        if (!string.IsNullOrEmpty(SessionCulture))
        {
            return cultures.Contains(SessionCulture) ? SessionCulture : string.Empty;
        }
        return string.Empty;
    }
    return string.Empty;
}

private static string GetBrowserCulture(HttpContext context, IEnumerable<string> cultures)
{
    /* Gets Languages from Browser */
    IList<string> BrowserLanguages = context.Request.UserLanguages;

    if (BrowserLanguages != null)
        foreach (var thisBrowserLanguage in BrowserLanguages)
        {
            foreach (var thisCultureLanguage in cultures)
            {
                if (thisCultureLanguage != thisBrowserLanguage) continue;
                return thisCultureLanguage;
            }
        }
    return string.Empty;
}

private static string SetCurrentLanguage(HttpContext context)
{
    //list ov available languages
    IList<string> Cultures = new List<string> { "en-US", "fr-CA" };

    string CookieValue = GetCookieCulture(context, Cultures);

    if (string.IsNullOrEmpty(CookieValue))
    {
        string sessionValue = GetSessionCulture(context, Cultures);
        if (string.IsNullOrEmpty(sessionValue))
        {
            string browserCulture = GetBrowserCulture(context, Cultures);
            return string.IsNullOrEmpty(browserCulture) ? "en-US" : browserCulture;
        }
        return sessionValue;
    }
    return CookieValue;
}

The GetCookieCulture, GetSessionCulture, GetBrowserCulture and SetCurrentLanguage are not required to be in the global.asax.cs file although I have put them there ease of explanation.  Some folks may look at this and decide to take this code and put it in an attribute and assign that attribute to every class.  This will cause problems when applying localization to the data annotations.  This is caused by the order of events in the ASP.NET MVC pipeline.

Once this code is in place lets add the following action to our Home controller.

public virtual ActionResult SetCulture(string id)
{
    HttpCookie userCookie = Request.Cookies["Culture"] ?? new HttpCookie("Culture");

    userCookie.Value = id;
    userCookie.Expires = DateTime.Now.AddYears(100);
    Response.SetCookie(userCookie);

    if (Request.UrlReferrer != null) return Redirect(Request.UrlReferrer.ToString());
   
    return RedirectToAction("Index", "Home");
}

Now we need to make a culture selector user control.  Since we want this to be available anywhere in the site lets add a new partial view to the Views | Shared and call it CultureUserControl.ascx.  Insert the following code into that user control.

<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl<dynamic>" %>
<%: Html.ActionLink("English", "SetCulture", "Home", new {id = "en-US"}, null) %> |
<%: Html.ActionLink("Français", "SetCulture", "Home", new { id = "fr-CA" }, null)%>

Now we need to place this user control in the master page so it viewable by pages:  Open the Views | Shared | Site.Master file.  Add the following code in the div with the id logindisplay like so:

<div id="logindisplay">
    <% Html.RenderPartial("CultureUserControl"); %>
    <% Html.RenderPartial("LogOnUserControl"); %>
</div>

Showing that it works
Before we can run the application and see any changes you will need to modify the Index view inside the Views | Home folder.  Change the title in that page to the following:

<asp:Content ID="Content1" ContentPlaceHolderID="TitleContent" runat="server">
    <%: ViewRes.Home.Index.PageTitle %>
</asp:Content>

Now lets modify the HomeController and edit the index method to look like this:

public ActionResult Index()
{
    ViewData["Message"] = ViewRes.Home.Index.Message;

    return View();
}

Run the application and click the French link next to the login link in the upper right hand corner.  Note that the Page Title and the welcome message are now in French.

Getting localization to work with Data Annotations
Getting localization to work with data annotations is not difficult but can be confusing.  Some attributes support localization and other do not.  Luckily you can create your own attributes to solve this problem.  That being said we will use two attributes Required which supports localization and DisplayName that does not support localization so we will have to create our own attribute.

Lets get started with the new DisplayName attribute.  At the root of the application create a folder called “Infrastructure” inside that folder create another folder called “Attributes”.  This is where all the attributes your create will go.  Lets create a new class in this folder called “LocalizedDisplayNameAttribute”.  Paste the following code into the file.

public class LocalizedDisplayNameAttribute : DisplayNameAttribute
{
    private PropertyInfo _nameProperty;
    private Type _resourceType;

    public LocalizedDisplayNameAttribute(string displayNameKey)
        : base(displayNameKey)
    { }

    public Type NameResourceType
    {
        get { return _resourceType; }
        set
        {
            _resourceType = value;
            //initialize nameProperty when type property is provided by setter 
            _nameProperty = _resourceType.GetProperty(base.DisplayName, BindingFlags.Static | BindingFlags.Public);
        }
    }
    public override string DisplayName
    {
        get
        {              //check if nameProperty is null and return original display name value 
            if (_nameProperty == null) { return base.DisplayName; }
            return (string)_nameProperty.GetValue(_nameProperty.DeclaringType, null);
        }
    }
}

Now we have a new DisplayName attribute that supports localization.  The next step would be to create the localization files needed for the Account | LogonModel class.  Using the section above lets create the following files in the Resources | Models | Accounts folder:

  • LogonModel.resx
  • LogonModel.fr.resx

When creating these files I used the custom tool namespace of “ModelRes.Account”.  You will see how this is used in a moment.  Now inside of both the resource files make sure they have the following name value pairs:

English version
Password = Password
PasswordRequired = Password is required
RememberMe = Remember Me?
Username = Username
UsernameRequired = Username is required

French version
Password = Mot de passe
PasswordRequired = Mot de passe est requis
RememberMe = Se souvenir de moi?
Username = Nom d'utilisateur
UsernameRequired = Nom d'utilisateur est nécessaire

Now let’s add the attributes to the class.  Open the Models | AccountModels class.  Here is the finished code for the LogOnModel class.

public class LogOnModel
{
    [Required(ErrorMessageResourceName = "UsernameRequired", ErrorMessageResourceType = typeof(ModelRes.Account.LogonModel))]
    [LocalizedDisplayName("Username", NameResourceType = typeof(ModelRes.Account.LogonModel))]
    public string UserName { get; set; }

    [Required(ErrorMessageResourceName = "PasswordRequired", ErrorMessageResourceType = typeof(ModelRes.Account.LogonModel))]
    [DataType(DataType.Password)]
    [LocalizedDisplayName("Password", NameResourceType = typeof(ModelRes.Account.LogonModel))]
    public string Password { get; set; }

    [LocalizedDisplayName("RememberMe", NameResourceType = typeof(ModelRes.Account.LogonModel))]
    public bool RememberMe { get; set; }
}

To test this lets start the application and click the logon link in the upper right hand corner.  On the login page you should see the labels already in English for username, password and remember me.  Click the French link in the upper right hand corner next to the logon link will cause those labels to now turn into French.  Clicking the login button without adding any information in the username and password field will cause a post back which should return validation errors.  Those should now be in French.  here is an example of what it should look like if everything worked correctly.
image

This should be enough to get you on your way to localizing an ASP.NET MVC 2 application with data annotations.

No comments:

Post a Comment