Monday, February 20, 2012

MVC ListBox using EditorTemplates and KnockoutJS

Editor templates are really handy tool when we want to create controls for a specific type. Recently I had a requiremnt to show winform styled ListBox control with lists for Available/Selected and allowing user to move items from one collection to other and move items up/down in selected collection. It was similar to this:
Step 1: Considering this as a pretty common functionality in our application,I created a ListSource class to support this UI.
MvcLists\Models\ListSource.cs
  1. using System.Collections.Generic;
  2.  
  3. namespace MvcLists.Models
  4. {
  5.     public class ListSource
  6.     {
  7.         public List<Item> Available { get; set; }
  8.         public List<Item> Selected { get; set; }
  9.     }
  10.  
  11.     public class Item
  12.     {
  13.         public int Value { get; set; }
  14.         public string Text { get; set; }
  15.     }
  16. }
Step 2: Add a property of type ListSource to Model.
MvcLists\Models\Person.cs
  1. public class Person
  2.     {
  3.         [Tooltip("Enter First Name")]
  4.         public string FirstName { get; set; }
  5.         [Tooltip("Enter Last Name")]
  6.         public string LastName { get; set; }
  7.         [Tooltip("Enter SSN")]
  8.         [Mask("999-99-9999")]
  9.         public string SSN { get; set; }
  10.         [Tooltip("Enter Age")]
  11.         [Mask("99")]
  12.         public string Age { get; set; }
  13.         [Mask("(999)-999-9999")]
  14.         [Tooltip("Enter Phone")]
  15.         public string Phone { get; set; }
  16.         [Mask("99999?-9999")]
  17.         [Tooltip("Enter Zip Code")]
  18.         public string ZipCode { get; set; }
  19.         [Mask("9999-9999-9999-9999")]
  20.         [Tooltip("Enter Credit Card")]
  21.         public string CreaditCard { get; set; }
  22.         [AutoComplete("Person", "GetStates", "state")]
  23.         public string State { get; set; }
  24.         [AutoComplete("Person", "GetStates", "state")]
  25.         public string USState { get; set; }
  26.         public ListSource Country { get; set; }
  27.     }
Step 3: Created an EditorTemplate for this type and added knockoutJS functions for move items left/right/up/down.
MvcLists\Views\Shared\EditorTemplates\ListSource.cshtml
  1. @model MvcLists.Models.ListSource
  2. <style type="text/css">
  3.     .divItem
  4.     {
  5.         float: none;
  6.         border-bottom-width: .2em;
  7.         border-bottom-style: solid;
  8.         border-bottom-color: #000000;
  9.         padding: 5px;
  10.         cursor: pointer;
  11.     }
  12.     .rightBoxItem
  13.     {
  14.         padding-left: 10px;
  15.     }
  16.     .indexCell
  17.     {
  18.         /*border-right-width: .2em;
  19.             border-right-style: solid;
  20.             border-right-color: #900;*/
  21.         padding: 5px;
  22.         padding-right: 10px;
  23.     }
  24.     .selectedItem
  25.     {
  26.         background: gold;
  27.     }
  28.     .header
  29.     {
  30.         font-family: arial;
  31.         color: #003469;
  32.         background-color: #dcdcdc;
  33.         margin: 0 0 10px 0;
  34.         padding: 2px 5px 2px 5px;
  35.     }
  36. </style>
  37. <script src="../../../Scripts/jquery-1.5.1.min.js" type="text/javascript"></script>
  38. <script src="../../../Scripts/knockout-2.0.0.js" type="text/javascript"></script>
  39. <script>
  40.         
  41.         function ListViewModel() {
  42.             var listItem = function(text,id,itemIndex) {
  43.                 this.Text = text;
  44.                 this.Id = id;
  45.             };
  46.             var self = this;
  47.             var listA = @Html.Raw(Json.Encode(Model.Available.ToList()));
  48.             var listB = @Html.Raw(Json.Encode(Model.Selected.ToList()));
  49.             self.listAArray= ko.observableArray(ko.utils.arrayMap(listA,function(list) {
  50.                 return new listItem(list.Text, list.Value,index);
  51.             }));
  52.             var index = 0;
  53.             self.listBArray= ko.observableArray(ko.utils.arrayMap(listB,function(list) {
  54.                 index++;
  55.                 return new listItem(list.Text, list.Value,index);
  56.             }));
  57.             self.changeClass = function(item) {
  58.                 $(item).closest("divItem").toggleClass("selectedItem");
  59.             };
  60.             self.moveRight = function() {
  61.                 $(".leftBox .selectedItem").each(function() {
  62.                     var item=ko.dataFor(this);
  63.                     self.listBArray.push(item);
  64.                     self.listAArray.remove(item);
  65.                 });
  66.             };
  67.             self.moveLeft = function() {
  68.                 $(".rightBox .selectedItem").each(function() {
  69.                     var item = ko.dataFor(this);
  70.                     self.listAArray.push(item);
  71.                     self.listBArray.remove(item);
  72.                     
  73.                 });
  74.             };
  75.             self.moveUp = function() {
  76.                 $(".rightBox .selectedItem").each(function() {
  77.                     var item = ko.dataFor(this);
  78.                     var index = self.listBArray.indexOf(item);
  79.                     if(index>0) {
  80.                         self.listBArray.remove(item);
  81.                         self.listBArray.splice(index - 1, 0, item);
  82.                         $(".rightBox .divItem:eq(" + (index -1) + ")").toggleClass("selectedItem");
  83.                     } else {
  84.                         return false;  
  85.                     }
  86.  
  87.                 });
  88.             };
  89.             self.moveDown = function() {
  90.                 $(".rightBox .selectedItem").each(function() {
  91.                     var item = ko.dataFor(this);
  92.                     var index = self.listBArray.indexOf(item);
  93.                     if(index<self.listBArray.length-1) {
  94.                         self.listBArray.remove(item);
  95.                         self.listBArray.splice(index + 1, 0, item);
  96.                         $(".rightBox .divItem:eq(" + (index +1) + ")").toggleClass("selectedItem");
  97.                     } else {
  98.                         return false;  
  99.                     }
  100.  
  101.                 });
  102.             };
  103.             
  104.             self.afterRender = function() {
  105.                 var i = 0;
  106.                 $(".rightBox .divItem").each(function() {
  107.                     i++;
  108.                     $(this).find(".indexCell")[0].text(i);
  109.                 });
  110.             };
  111.             ko.bindingHandlers.Index = {
  112.                 update:function (element,valueAccessor) {
  113.                     var i = 0;
  114.                     $(".rightBox .indexCell").each(function() {
  115.                         i++;
  116.                         $(this).text(i);
  117.                     });
  118.                     $(".divItem").unbind("click").bind("click", function() {
  119.                         $(this).toggleClass("selectedItem");
  120.                     });
  121.                 }
  122.             };
  123.         }
  124.  
  125.         $(document).ready(function () {
  126.             ko.applyBindings(new ListViewModel());
  127.         });
  128. </script>
  129. @Html.LabelForModel()
  130. <div style="margin-left: 5px; width: 100%;">
  131.     <div style="margin: auto; width: 90%;">
  132.         <div>
  133.             <div style="float: left">
  134.                 <div>
  135.                     <span>Available</span>
  136.                 </div>
  137.                 <div data-bind="foreach:listAArray" style="color: black; background-color: #dcdcdc;
  138.                     display: inline-block; border-style: solid; border-width: 2px; border-bottom-width: 0px;
  139.                     border-color: #000000" class="leftBox">
  140.                     <div class="divItem">
  141.                         <span data-bind="text:Text"></span>
  142.                     </div>
  143.                 </div>
  144.             </div>
  145.             <div style="float: left; vertical-align: middle; padding: 5px; display: inline-block;
  146.                 margin-top: 50px; width: 75px;">
  147.                 <div>
  148.                     <div>
  149.                         <input type="button" data-bind="click:moveRight" value=">" /></div>
  150.                     <div>
  151.                         <input type="button" data-bind="click:moveLeft" value="<" /></div>
  152.                 </div>
  153.             </div>
  154.             <div style="float: left">
  155.                 <div>
  156.                     <span>Selected</span>
  157.                 </div>
  158.                 <div class="rightBox" data-bind="foreach:listBArray,Index:listBArray" style="
  159.                     background-color: #FFFFAA; display: inline-block; border-style: solid; border-width: 2px;
  160.                     border-color: #000000; border-bottom-width: 0px">
  161.                     <span class="indexCell" style="float: left;"></span>
  162.                     <div class="divItem rightBoxItem">
  163.                         <span data-bind="text:Text"></span>
  164.                     </div>
  165.                 </div>
  166.             </div>
  167.             <div style="float: left; vertical-align: middle; padding: 5px; display: inline-block;
  168.                 margin-top: 50px; width: 75px;">
  169.                 <div style="background-color: bisque;">
  170.                     <div>
  171.                         <input type="button" data-bind="click:moveUp" value="Move Up" /></div>
  172.                     <div>
  173.                         <input type="button" data-bind="click:moveDown" value="Move Down" /></div>
  174.                 </div>
  175.             </div>
  176.             <div style="clear: both">
  177.             </div>
  178.         </div>
  179.     </div>
  180. </div>
Step 4: In controller/viewModel set DataSource for list box.
MvcLists\Controllers\PersonController.cs
  1. public ListSource Countries
  2.         {
  3.             get
  4.             {
  5.                 return new ListSource
  6.                            {
  7.                                Available = new List<Item>
  8.                                                {
  9.                                                    new Item {Text = "US", Value = 1},
  10.                                                    new Item {Text = "UK", Value = 2},
  11.                                                    new Item {Text = "Canada", Value = 3},
  12.                                                    new Item {Text = "France", Value = 4}
  13.                                                },
  14.                                Selected = new List<Item> {new Item {Text = "India", Value = 5}}
  15.                            };
  16.             }
  17.         }
Step 5: Render this listbox(Country)
MvcLists\Views\Person\Index.cshtml
  1. @using MvcLists.Common.HtmlHelpers
  2. @model MvcLists.Models.Person
  3. @{
  4.     ViewBag.Title = "Index";
  5.     Layout = "~/Views/Shared/_Layout.cshtml";
  6. }
  7. <h2>
  8.     Index</h2>
  9. <script src="@Url.Content("~/Scripts/jquery.validate.min.js")" type="text/javascript"></script>
  10. <script src="../../Scripts/jquery.maskedinput.js" type="text/javascript"></script>
  11. @using (Html.BeginForm("Index","Person"))
  12. {
  13.     <fieldset>
  14.         <legend>Person</legend>
  15.         <div class="editor-label">
  16.             @Html.LabelFor(model => model.FirstName)
  17.         </div>
  18.         <div class="editor-field">
  19.             @Html.TextBoxFor(model => model.FirstName,new{title=@Html.TooltipFor(x=>x.FirstName)})
  20.         </div>
  21.         <div class="editor-label">
  22.             @Html.LabelFor(model => model.LastName)
  23.         </div>
  24.         <div class="editor-field">
  25.             @Html.TextBoxFor(model => model.LastName,new{title=Html.TooltipFor(x=>x.LastName)})
  26.         </div>
  27.         @Html.EditorFor(x => x.SSN)
  28.         @Html.EditorFor(x => x.Age)
  29.         @Html.EditorFor(x => x.Phone)
  30.         @Html.EditorFor(x => x.CreaditCard)
  31.         @Html.EditorFor(x => x.ZipCode)
  32.         @Html.EditorFor(x=>x.State)
  33.         @Html.EditorFor(x=>x.USState)
  34.         @Html.EditorFor(x=>x.Country)
  35.  
  36.         <p>
  37.             <input type="submit" value="Create" />
  38.         </p>
  39.     </fieldset>
  40. }
  41. <div>
  42.     @Html.ActionLink("Back to List", "Index")
  43. </div>

Sunday, February 19, 2012

MVC Mask Attribute for Inputs

I created a mask input in previous posts where I declared Jquery as a constant and added it to Scripts collection. Alternate approach can be generate an Id for each mask input and use scripts in EditorTemplate.
MaskAttribute
  1. public void OnMetadataCreated(ModelMetadata metadata)
  2.         {
  3.             metadata.TemplateHint = "_maskInput";
  4.             metadata.AdditionalValues["id"] = Guid.NewGuid();
  5.             metadata.AdditionalValues["mask"] = Mask;
  6.         }
Register mask for input in document ready event.
Views\Shared\EditorTemplates\_maskInput.cshtml
  1. @using MvcLists.Common.CustomAttributes
  2. @model System.String
  3. @{
  4.     var additionalValues = ViewData.ModelMetadata.AdditionalValues;
  5.     var mask = additionalValues.SingleOrDefault(x=>x.Key=="mask").Value;
  6.     var id = additionalValues.SingleOrDefault(x => x.Key == "id").Value;
  7. }
  8. @{ var maskedInput = ViewData.GetModelAttribute<MaskAttribute>();
  9.    if (maskedInput != null)
  10.    {
  11.         <div class="editor-label">
  12.             @Html.LabelForModel()
  13.         </div>
  14.         <div class="editor-field">
  15.             @Html.TextBoxFor(m => m, new { id = @id })
  16.         </div>
  17.    }
  18. }
  19. <script type="text/javascript">
  20.     $(document).ready(function () {
  21.         $("#@id").mask('@mask');
  22.     });
  23. </script>

Saturday, February 18, 2012

MVC strip mask characters

In Previous post,I created masked inputs but on form post,it is posting mask characters as well. We can strip these characters using ModelBinders and thus can post numeric data only.
Common\ModelBinders\StripMaskCharacters.cs
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Linq;
  4. using System.Text.RegularExpressions;
  5. using System.Web;
  6. using System.Web.Mvc;
  7. using MvcLists.Common.CustomAttributes;
  8.  
  9. namespace MvcLists.Common.ModelBinders
  10. {
  11.     public class StripMaskCharacters : DefaultModelBinder
  12.     {
  13.         protected override void SetProperty(ControllerContext controllerContext,
  14.                                             ModelBindingContext bindingContext,
  15.                                             System.ComponentModel.PropertyDescriptor propertyDescriptor,
  16.                                             object value)
  17.         {
  18.            if(value!=null && propertyDescriptor.PropertyType==(typeof(string)))
  19.            {
  20.                value = ((string) value).Trim();
  21.                if ((string)value == string.Empty)
  22.                {
  23.                    value = null;
  24.                }
  25.                else if(propertyDescriptor.Attributes[typeof(MaskAttribute)]!=null
  26.                    && bindingContext.ValueProvider.GetValue(propertyDescriptor.Name)!=null
  27.                    && bindingContext.ValueProvider.GetValue(propertyDescriptor.Name).AttemptedValue!=null)
  28.                {
  29.                    value = Regex.Replace(bindingContext.ValueProvider.GetValue(propertyDescriptor.Name).AttemptedValue,
  30.                                          "[^0-9]", string.Empty);
  31.                }
  32.            }
  33.             base.SetProperty(controllerContext, bindingContext, propertyDescriptor, value);
  34.         }
  35.     }
  36. }
Then register this model binder in Application_Start of global.asax
Global.asax
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Linq;
  4. using System.Web;
  5. using System.Web.Mvc;
  6. using System.Web.Routing;
  7. using FluentValidation.Mvc;
  8. using MvcLists.Common.DataAnnotations;
  9. using MvcLists.Common.ModelBinders;
  10.  
  11. namespace MvcLists
  12. {
  13.     // Note: For instructions on enabling IIS6 or IIS7 classic mode,
  14.     // visit http://go.microsoft.com/?LinkId=9394801
  15.  
  16.     public class MvcApplication : System.Web.HttpApplication
  17.     {
  18.         public static void RegisterGlobalFilters(GlobalFilterCollection filters)
  19.         {
  20.             filters.Add(new HandleErrorAttribute());
  21.         }
  22.  
  23.         public static void RegisterRoutes(RouteCollection routes)
  24.         {
  25.             routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
  26.  
  27.             routes.MapRoute(
  28.                 "Default", // Route name
  29.                 "{controller}/{action}/{id}", // URL with parameters
  30.                 new { controller = "Person", action = "Index", id = UrlParameter.Optional } // Parameter defaults
  31.             );
  32.  
  33.         }
  34.  
  35.         protected void Application_Start()
  36.         {
  37.             AreaRegistration.RegisterAllAreas();
  38.  
  39.             RegisterGlobalFilters(GlobalFilters.Filters);
  40.             RegisterRoutes(RouteTable.Routes);
  41.             ModelMetadataProviders.Current=new MyModelMetaDataProvider();
  42.             ModelBinders.Binders.DefaultBinder = new StripMaskCharacters();
  43.             FluentValidationModelValidatorProvider.Configure();
  44.         }
  45.         protected void Application_BeginRequest()
  46.         {
  47.             HttpContext.Current.Response.Cache.SetExpires(DateTime.UtcNow.AddDays(-1));
  48.             HttpContext.Current.Response.Cache.SetValidUntilExpires(false);
  49.             HttpContext.Current.Response.Cache.SetRevalidation(HttpCacheRevalidation.AllCaches);
  50.             HttpContext.Current.Response.Cache.SetCacheability(HttpCacheability.NoCache);
  51.             HttpContext.Current.Response.Cache.SetNoStore();
  52.  
  53.         }
  54.     }
  55. }

MVC Masked Input

One can use jquery to mask the inputs.There are quiet a few good plugins available for this e.g. http://digitalbush.com/projects/masked-input-plugin/

Only drawback to this approach is we need to link jquery function for each input to be masked.
Alternate approach can be creating a data annotation for mask and use it on model/viwModel.

Step 1: Create a mask attribute
Common\CustomAttributes\MaskAttribute.cs
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Linq;
  4. using System.Web;
  5. using System.Web.Mvc;
  6.  
  7. namespace MvcLists.Common.CustomAttributes
  8. {
  9.     [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
  10.     public class MaskAttribute:Attribute,IMetadataAware
  11.     {
  12.         private string _mask = string.Empty;
  13.         public MaskAttribute(string mask)
  14.         {
  15.             _mask = mask;
  16.         }
  17.  
  18.         public string Mask
  19.         {
  20.             get { return _mask; }
  21.         }
  22.  
  23.         private const string ScriptText = "<script type='text/javascript'>" +
  24.                                            "$(document).ready(function () {{" +
  25.                                            "$('#{0}').mask('{1}');}});</script>";
  26.  
  27.         public const string templateHint = "_maskedInput";
  28.  
  29.         private int _count;
  30.  
  31.         public string Id
  32.         {
  33.             get { return "maskedInput_" + _count; }
  34.         }
  35.  
  36.         internal HttpContextBase Context
  37.         {
  38.             get { return new HttpContextWrapper(HttpContext.Current); }
  39.         }
  40.  
  41.         public void OnMetadataCreated(ModelMetadata metadata)
  42.         {
  43.             var list = Context.Items["Scripts"] as IList<string> ?? new List<string>();
  44.             _count = list.Count;
  45.             metadata.TemplateHint = templateHint;
  46.             metadata.AdditionalValues[templateHint] = Id;
  47.             list.Add(string.Format(ScriptText, Id, Mask));
  48.             Context.Items["Scripts"] = list;
  49.         }
  50.     }
  51. }
Step 2:Create a partial view in EditorTemplates and add an extension method for ViewDataDictionary to get the attribute for the property
Views\Shared\EditorTemplates\_mask.cshtml
  1. @using MvcLists.Common.CustomAttributes
  2. @model System.String
  3. @{ var maskedInput = ViewData.GetModelAttribute<MaskAttribute>();
  4.    if (maskedInput != null)
  5.    {
  6.         <div class="editor-label">
  7.             @Html.LabelForModel()
  8.         </div>
  9.         <div class="editor-field">
  10.             @Html.TextBoxFor(m => m, new { id = ViewData.ModelMetadata.AdditionalValues[MaskAttribute.templateHint] })
  11.         </div>
  12.    }
  13. }
Common\MvcExtensions\ViewDataExtensions.cs
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Linq;
  4. using System.Web;
  5. using System.Web.Mvc;
  6.  
  7. namespace MvcLists.Common.MvcExtensions
  8. {
  9.     public static class ViewDataExtensions
  10.     {
  11.         public static TAttribute GetModelAttribute<TAttribute>(this ViewDataDictionary viewData,bool inherit=false) where TAttribute:Attribute
  12.         {
  13.             if(viewData==null) throw new ArgumentException("ViewData");
  14.             var containerType = viewData.ModelMetadata.ContainerType;
  15.             return
  16.                 ((TAttribute[])
  17.                  containerType.GetProperty(viewData.ModelMetadata.PropertyName).GetCustomAttributes(typeof (TAttribute),
  18.                                                                                                     inherit)).
  19.                     FirstOrDefault();
  20.                     
  21.         }
  22.     }
  23. }
Common\HtmlHelpers\HtmlHelpers.cs
  1. public static IHtmlString RenderScripts(this HtmlHelper htmlHelper)
  2.         {
  3.             var scripts = htmlHelper.ViewContext.HttpContext.Items["Scripts"] as IList<string>;
  4.             if (scripts != null)
  5.             {
  6.                 var builder = new StringBuilder();
  7.                 foreach (var script in scripts)
  8.                 {
  9.                     builder.AppendLine(script);
  10.                 }
  11.                 return new MvcHtmlString(builder.ToString());
  12.             }
  13.             return null;
  14.         }
Step 3: Register this new extension in web.config of Views folder.
Web.config
  1.  
  2.   < system.web.webPages.razor >
  3.     < host factoryType= "System.Web.Mvc.MvcWebRazorHostFactory, System.Web.Mvc, Version=3.0.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35" />
  4.     < pages pageBaseType= "System.Web.Mvc.WebViewPage" >
  5.       < namespaces >
  6.         < add namespace= "System.Web.Mvc" />
  7.         < add namespace= "System.Web.Mvc.Ajax" />
  8.         < add namespace= "System.Web.Mvc.Html" />
  9.         < add namespace= "System.Web.Routing" />
  10.         < add namespace= "MvcLists.Common.MvcExtensions" />
  11.       </ namespaces >
  12.     </ pages >
  13.   </ system.web.webPages.razor >
Step 4: Use this attribute on Model/ViewModels property.
Models\Person.cs
  1. public class Person
  2.     {
  3.         [Tooltip("Enter First Name")]
  4.         public string FirstName { get; set; }
  5.         [Tooltip("Enter Last Name")]
  6.         public string LastName { get; set; }
  7.         [Tooltip("Enter SSN")]
  8.         [Mask("999-99-9999")]
  9.         public string SSN { get; set; }
  10.         [Tooltip("Enter Age")]
  11.         [Mask("99")]
  12.         public string Age { get; set; }
  13.         [Mask("999-999-9999")]
  14.         [Tooltip("Enter Phone")]
  15.         public string Phone { get; set; }
  16.         [Mask("99999-9999")]
  17.         [Tooltip("Enter Zip Code")]
  18.         public string ZipCode { get; set; }
  19.         [Mask("9999-9999-9999-9999")]
  20.         [Tooltip("Enter Credit Card")]
  21.         public string CreaditCard { get; set; }
  22.  
  23.     }
Step 5: Render the UI.
Views\Person\Index.cshtml
  1. @using (Html.BeginForm("Index","Person"))
  2. {
  3.     @Html.ValidationSummary(true)
  4.     <fieldset>
  5.         <legend>Person</legend>
  6.         <div class="editor-label">
  7.             @Html.LabelFor(model => model.FirstName)
  8.         </div>
  9.         <div class="editor-field">
  10.             @Html.TextBoxFor(model => model.FirstName,new{title=@Html.TooltipFor(x=>x.FirstName)})
  11.             @Html.ValidationMessageFor(model => model.FirstName)
  12.         </div>
  13.         <div class="editor-label">
  14.             @Html.LabelFor(model => model.LastName)
  15.         </div>
  16.         <div class="editor-field">
  17.             @Html.TextBoxFor(model => model.LastName,new{title=Html.TooltipFor(x=>x.LastName)})
  18.             @Html.ValidationMessageFor(model => model.LastName)
  19.         </div>
  20.         @Html.EditorFor(x => x.SSN)
  21.         @Html.EditorFor(x => x.Age)
  22.         @Html.EditorFor(x => x.Phone)
  23.         @Html.EditorFor(x => x.CreaditCard)
  24.         @Html.EditorFor(x => x.ZipCode)
  25.         <p>
  26.             <input type="submit" value="Create" />
  27.         </p>
  28.     </fieldset>
  29. }
Output: