piątek, 12 kwietnia 2013

Autocomplete control with ASP.NET MVC 4 and jQuery

Almost in each modern website project one of the feature is suggesting user possible items to select when he start typing one of them. Such functionality is done by using control named autocomplete. Under the hood  it consists of at least three elements:
  • UI control which allow user to type some text - mainly this in HTML input of text type
  • Server-side function which serves data to be auto-completed
  • Client-side logic (written in JavaScript) which , by using AJAX, send request to the server asynchronously and then process the results.
When we think about creating autocomplete control in ASP.NET MVC 4 we definitely should take a look at jQueryUI framework. One of the feature of this framework is autocomplete control which enables users to quickly find and select from a pre-populated list of values as they type, leveraging searching and filtering. That sounds very good and is excellently what we are looking for.

First step in autocomplete control  creation is creating a new MVC 4 project in Visual Studio 2012 (picture 1.) and set it as Internet Application with Razor engine. After whole project created successfully , press F5 and validate build result - should complete without any error.
Picture 1. Creating MVC 4 project.
Default ASP.MVC  4 project template already contains several very important features.One of them, which will be very usefull in our case, is predefined set of the JavaScript files (including jQuery and jQueryUI) located in the ~/Scripts folder. Other stuff is connected with membership provider and won`t be used in this tutorial, but to make it easier lets stay with template structure without removing anything.

First think we need to add to our solution in our custom model which will store information for view. The assumption here is we want to create two auto-completes that will be part of new AdminController. First one will be responsible for auto-completing car brand and the second will help with choosing proper globalization. Based on this information we can create our first class model which will be located in ~/Models/Admin/ManageModel.cs .Our model will store ifnroation about SelectedCarBrandId and culture Id - very simple.

Code Snippet
  1. namespace Autocomplete.Models.Admin
  2. {
  3.     /// <summary>
  4.     /// Stores information for manage view.
  5.     /// </summary>
  6.     public class ManageModel
  7.     {
  8.         /// <summary>
  9.         /// Gets or sets the selected car brand id.
  10.         /// </summary>
  11.         public int? SelectedCarBrandId { get; set; }
  13.         /// <summary>
  14.         /// Gets or sets the selected culture code.
  15.         /// </summary>
  16.         public string SelectedCultureCode { get; set; }
  17.     }
  18. }

For C# beginners worth to explain is type of the SelectedCarBrandId - this is  int?. Integer is value type so this mean that has default value assigned after declaration (0 at this case). To allow put null value to object of integer type we need to declare our type as Nullable<T> struct and this is equals with the int? syntax. Thanks doing that we can type int? integerValue = null;

After we done with model, now it`s time for creating new controller (in ~/Controller/AdminController.cs). Creating new controller cause that we have new route value available (http://localhost/Admin/) in our project but there is no view to present. But focus at the controller first. Our AdminController containts two function and both of them returns ActionResults (read - returns view). Name of this function is also the same  (so we can expect new action available on website http://localhost/Admin/Manage) but whole signature is different. First of all second fuction takes model as parameter and additionally is decorated with HttpPostAttribute . The reason why it is done in that way is difference between how to get this two action. When user open page under the hood uses GET request without any parameters (in this case). Then he changing something on the website and click the submit button - it causes POST request and sends HTML form to the server. That is why we have two function where first (GET) function just render the view and the second (POST) process provider by user data (this data is binded to the model of the ManageModel type  at this case).

Code Snippet
  1. namespace Autocomplete.Controllers
  2. {
  3.     using System.Collections.Generic;
  4.     using System.Globalization;
  5.     using System.Linq;
  6.     using System.Web.Mvc;
  8.     using Autocomplete.Models.Admin;
  10.     /// <summary>
  11.     /// Controlles for admini purposes.
  12.     /// </summary>
  13.     public class AdminController : Controller
  14.     {
  15.         /// <summary>
  16.         /// Generate view for main admin page.
  17.         /// </summary>
  18.         /// <returns>View of the main page.</returns>
  19.         public ActionResult Manage()
  20.         {
  21.             ManageModel model = new ManageModel();
  22.             return View(model);
  23.         }
  25.         [HttpPost]
  26.         public ActionResult Manage(ManageModel model)
  27.         {
  28.             return View();
  29.         }
  30.     }
  31. }

Next step is create a simple view to display UI  for the user. Our view, called Manage.cshtml (picture 2.) will be stored in the following location ~/Views/Admin/Manage.cshtml and will take main layout from parent page which in this case will be _Layout.cshtml.

Picture 2. Adding view with default layout.
Just after creation our new Manage view contains some predefined tags and the most important at this stage is connection between a view and their layout (line 5). On this view we wish to create our two auto-completes (which calls separate actions) but before during that we need to create a form. The reason why we wan to  use form is that we want to send data from UI to server and this need to be done thanks form element. Form can be generated  easily by using BeginForm function from HtmlHelper (line 8). The first parameter of this function represent action name (function on the controller that we want to call after a POST) and controller name as the second. To make a POST to the sever we need also provide submit button which is simple input of submit type (line 13).

Code Snippet
  1. @using Autocomplete.Helpers;
  2. @model Autocomplete.Models.Admin.ManageModel
  3. @{
  4.     ViewBag.Title = "Manage";
  5.     Layout = "~/Views/Shared/_Layout.cshtml";
  6. }

  7. @using (Html.BeginForm("Manage", "Admin"))
  8. {
  9.     <h2>
  10.         Manage</h2>
  12.     <input type="submit" value="Submit" />
  13. }

Now it`s time for achieve our goal and introduce auto-completes controls. Before we do that we need make sure that we provided model for our view already (we want to use ManageModel for Manage view) which allow us to work with a strongly typed view later. Making such connection is quite easy and its noting else then putting namespace declaration for model and calling model type with a @model directive (line 1 and 2 above). Next we switch to implementation of our helper method which will extend standard HtmlHelper by adding new extension function with partial signature MvcHtmlString AutocompleteFor<TModel, TValue>. The most important thing in the function signature is that it takes model type (TModel) and by expression also takes model properties as TValue. Method has two overloaded definition (simple and more advance) but in both cases the most important thing is actionUrl parameters which defines controller action which will be called when user types more then two characters into auto-complete (will be introduced later).

Code Snippet
  1. namespace Autocomplete.Helpers
  2. {
  3.     using System.Linq.Expressions;
  4.     using System.Text;
  5.     using System.Web.Mvc;
  6.     using System.Web.Mvc.Html;
  7.     using System;
  8.     using System.Collections.Generic;
  9.     using System.Linq;
  10.     using System.Web;
  12.     public static class AutocompleteHelper
  13.     {
  14.         /// <summary>
  15.         /// Create autocomplete control by using jQueryUi.
  16.         /// </summary>
  17.         /// <typeparam name="TModel">Type of the view model.</typeparam>
  18.         /// <typeparam name="TValue">Selected properties type.</typeparam>
  19.         /// <param name="helper">Html helper to extend.</param>
  20.         /// <param name="expression">The HTML helper instance that this method extends.</param>
  21.         /// <param name="actionUrl">Controller action to call after user start typing.</param>
  22.         /// <returns>Autocomplete HTML string.</returns>
  23.         public static MvcHtmlString AutocompleteFor<TModel, TValue>(this HtmlHelper<TModel> helper, Expression<Func<TModel, TValue>> expression, string actionUrl)
  24.         {
  25.             return CreateAutocomplete(helper, expression, actionUrl, null, null);
  26.         }
  28.         /// <summary>
  29.         /// Create autocomplete control by using jQueryUi with additional parameters.
  30.         /// </summary>
  31.         /// <typeparam name="TModel">Type of the view model.</typeparam>
  32.         /// <typeparam name="TValue">Selected properties type.</typeparam>
  33.         /// <param name="helper">Html helper to extend.</param>
  34.         /// <param name="expression">The HTML helper instance that this method extends.</param>
  35.         /// <param name="actionUrl">Controller action to call after user start typing.</param>
  36.         /// <param name="isRequired">Determines field is required.</param>
  37.         /// <param name="placeholder">Placeholder text to present when control containts no data.</param>
  38.         /// <returns>Autocomplete HTML string.</returns>
  39.         public static MvcHtmlString AutocompleteFor<TModel, TValue>(this HtmlHelper<TModel> helper, Expression<Func<TModel, TValue>> expression, string actionUrl, bool? isRequired, string placeholder)
  40.         {
  41.             return CreateAutocomplete(helper, expression, actionUrl, placeholder, isRequired);
  42.         }
  43.     }
  44. }

In the implementation above both function calls the same method in the function body. That function is responsible for creating HTML tags for auto-complete control based of passed parameters. There is several important places inside this function that is worth to discuss:
  • line 49: contains  adding attribute for the input control that will be kind of representative for auto-complete control in whole solution.
  • line 50: URL action to be called after user typed more then two characters  in control. 
  • line 66: creating <input type='hidden' />  for storing value (not label) selected by user from auto-complete. Name of the value is equal to the property name indicated in expression (field of the model). This also set default value based on model value.
  • line 68: add to the auto-complete attribute which will store name of the associated hidden control.
  • line 71: creating input with set of attributes that will be used by JavaScript to detect and generate auto-complete during run-time.
Code Snippet
  1. private static MvcHtmlString CreateAutocomplete<TModel, TValue>(HtmlHelper<TModel> helper, Expression<Func<TModel, TValue>> expression, string actionUrl, string placeholder, bool? isRequired)
  2.         {
  3.             var attributes = new Dictionary<string, object>
  4.                                  {
  5.                                      { "data-autocomplete", true },
  6.                                      { "data-action", actionUrl }
  7.                                  };
  9.             if (string.IsNullOrWhiteSpace(placeholder))
  10.             {
  11.                 attributes.Add("placeholder", placeholder);
  12.             }
  14.             if (isRequired.HasValue && isRequired.Value)
  15.             {
  16.                 attributes.Add("required", "required");
  17.             }
  19.             Func<TModel, TValue> method = expression.Compile();
  20.             var value = method((TModel)helper.ViewData.Model);
  21.       var baseProperty=((MemberExpression) expression.Body).Member.Name;
  22.             var hidden = helper.Hidden(baseProperty, value);
  24.             attributes.Add("data-value-name", baseProperty);
  26.             var automcompleteName = baseProperty + "_autocomplete";
  27.             var textBox = helper.TextBox(automcompleteName, null, string.Empty, attributes);
  29.             var builder = new StringBuilder();
  30.             builder.AppendLine(hidden.ToHtmlString());
  31.             builder.AppendLine(textBox.ToHtmlString());
  33.             return new MvcHtmlString(builder.ToString());
  34.         }

Lets now focus on the auto-complete logic. Because some action of an auto-complete takes place on the client-side we need to use JavaScript code to create such behavior and also to send AJAX request and process data from a response. First of all lets then create new file AutocompleteScript.js located in the path ~/Script/AutocompleteScript.js (code below). After website is fully loaded we call CreateAutocomplete function (line 1-3). This function under the hood by using jQuery attribute selector gathers all elements with a data-autocomplete attribute (and our auto-complete will have such one). Later it iterates through selected collection by using each function and process each element separately. During processing is takes URL action form which point to controller function (line 7) in the next line call auto-complete creation. Inside that creation, except success function that will be called after user select item from suggestion list, we have source which is simple AJAX call with parameters:
  • url: action on the controller.
  • dataType: set as jSon.
  • data: provided by the user text inside the control which will be used to filter the result set.
  • success: process the response and fill suggestion list
Code Snippet
  1. $(document).ready(function () {
  2.     CreateAutocomplete();
  3. });
  5. function CreateAutocomplete() {
  6.     var inputsToProcess = $('[data-autocomplete]').each(function (index, element) {
  7.         var requestUrl = $(element).attr('data-action');
  9.         $(element).autocomplete({
  10.             minLength: 2,
  11.             source: function (request, response) {
  12.                 $.ajax({
  13.                     url: requestUrl,
  14.                     dataType: "json",
  15.                     data: { query: request.term },
  16.                     success: function (data) {
  17.                         response($.map(data, function (item) {
  18.                             return {
  19.                                 label: item.Label,
  20.                                 value: item.Label,
  21.                                 realValue: item.Value
  22.                             };
  23.                         }));
  24.                     },
  25.                 });
  26.             },
  27.             select: function (event, ui) {
  28.                 var hiddenFieldName = $(this).attr('data-value-name');
  29.                 $('#'+hiddenFieldName).val(ui.item.realValue);
  30.             }
  31.         });
  32.     });
  33. }

We almost done and our solution is almost ready. We heed to complete just several step more:

1) We put client-side auto-complete login in separated JavaScript file that is why we need to attach it to our view. To do that first of all it need to become part of the ScriptBundle object - so the code presented below need to be added to the ~/App_Start/BundleConfig.cs file. Thanks  doing that on our Manage view we can then put line @Scripts.Render("~/bundles/jquery").

Code Snippet
  1. bundles.Add(new ScriptBundle("~/bundles/custom").Include(
  2.           "~/Scripts/AutocompleteScript.js"));

2)  Create two AdminController methods that will provide data for auto-completes. Remember here that parameter name have to be equals 'query' and jSon result must AllowGet.

Code Snippet
  1. /// <summary>
  2. /// Autocompletes car brand
  3. /// </summary>
  4. /// <param name="query">Search term.</param>
  5. /// <returns>Json with search result.</returns>
  6. [HttpGet]
  7. public JsonResult AutocompleteCars(string query)
  8. {
  9.     var data = new Dictionary<int, string>
  10.                    {
  11.                        { 1, "Audi" },
  12.                        { 2, "BMW" },
  13.                        { 3, "Opel" },
  14.                        { 4, "Citroen" },
  15.                        { 5, "Toyota" }
  16.                    };
  17.     query = query.ToLower();
  18.     var result = data.Where(c => c.Value.ToLower().StartsWith(query)).Select(c => new { Value = c.Key, Label = c.Value });
  19.     return this.Json(result, JsonRequestBehavior.AllowGet);
  20. }
  22. /// <summary>
  23. /// Autocomplete culture by term.
  24. /// </summary>
  25. /// <param name="query">Term to search.</param>
  26. /// <returns>Json with cultures.</returns>
  27. [HttpGet]
  28. public JsonResult AutocompleteCulture(string query)
  29. {
  30.     query = query.ToLower().Trim();
  31.     var result = CultureInfo.GetCultures(CultureTypes.SpecificCultures)
  32.           .Where(c => c.DisplayName.ToLower().StartsWith(query))
  33.           .Select(c => new { Value = c.LCID, Label = c.DisplayName });
  35.     return this.Json(result, JsonRequestBehavior.AllowGet);
  36. }

3) Add our auto-complete extension function to the view and provide required parameters:

  • Model property indicated by the expression
  • Controller function generated by Action.

Code Snippet
  2. @Styles.Render("~/Content/themes/base/css")
  3. @using (Html.BeginForm("Manage", "Admin"))
  4. {
  5.     <h2>
  6.         Manage</h2>
  7.     <span>Car brand:</span> @Html.AutocompleteFor(c => c.SelectedCarId, Url.Action("AutocompleteCars", "Admin"))
  8.     <br />
  9.     <span>Globalization name:</span>@Html.AutocompleteFor(c => c.SelectedCultureCode, Url.Action("AutocompleteCulture", "Admin"), true, "Start typing culture name....")
  10.     <br />
  11.     <input type="submit" value="Submit" />
  12. }

4) Run the solution and go to the http://localhost:port/Admin/Manage  and feel  free to test.

Source code is available here.