Click here to Skip to main content
Click here to Skip to main content

MVC Custom Select Control

, 5 May 2014 CPOL
Rate this:
Please Sign up or sign in to vote.
MVC HtmlHelper class used in conjunction with a JQuery plugin to generate a custom select control that provides complex property postback, keyboard filtering of items, grouped and hierarchical displays, optional AJAX loading and CSS styling of items.

Introduction

The standard HTML select control has some limitations that I wanted to overcome including:

  • it only displays a single property and only posts back a single property
  • has limited ability to style the items in the list
  • has limited ability to search objects (only by 'starts with' and you need to type really fast if you are searching by more than the first character)

Used in conjunction with the JQuery plugin, this HtmlHelper generates a custom HTML select control that:

  • If attached to a complex property, will update and post back all properties of the class when an item is selected
  • If no item is selected, prevents post back of all properties of the class (i.e. the value of a complex property will be null)
  • Allows CSS styling of the items in the drop-down
  • Depending on property type and collection type passed to the helper, will render flat, grouped or hierarchical item displays
  • Has keyboard search and filtering (including search anywhere within an items text depending on the number of characters entered) and be able to do it at a leisurely pace
  • Allows optional AJAX loading based on the search text by passing the name of a controller, action method and parameter name
  • Can have it's items populated with JSON data via a separate javascript call (e.g. to create cascading selects)
  • Raises events when the selected item changes and when items are added to the list
  • Has similar behaviour to the standard HTML select control (mouse and keyboard navigation)

Usage

The helper has a number of overloads that render different types of collection structures:

@Html.SelectFor(m => m.MyEnum)

will render a select for an enum, where the list contains each value of the enumeration. If the System.ComponentModel.DataAnnotations DescriptionAttribute attribute has been applied to an enum value, the description text will be displayed allowing for user friendly names.

@Html.SelectFor(m => m.ValueType, MyCollection as IEnumerable)

will render and postback a simple property (I can't think of any use other than a string but it's possible to use any value type). The collection can be of any type (most obviously IEnumerable<string> but can be a complex type) and is displayed and posted back using it's .ToString() method.

@Html.SelectFor(m => m.MyComplexProperty, MyCollection as IEnumerable, 
  "idProperty", "displayProperty")

will render and postback a complex property. The helper renders a hidden input for each property of the class (called recursively if the property itself is a complex property) that is updated when a selection is made. The collection must be a generic type that is (or is derived from) the property type. If the complex property contains a property that is a collection of it's own type, then a hierarchical list is displayed. If the collection is IDictionary<string, IEnumerable> then a grouped list is displayed using the dictionary key as the group heading (refer image below), otherwise a flat list is displayed. The idProperty is the name of the property in the complex type that uniquely identifies the model within the collection, and displayProperty is the name of the property used to display the item.

@Html.SelectFor(m => m.MyProperty, "idProperty", "displayProperty", 
  "controller", "action", "actionParameter")

will render and post back a value type or complex type where the items are loaded using AJAX (jQuery.getJSON(...)) based on the search text. The structure of the JSON data determines if a flat, grouped or hierarchical list is created.

Finally, to wire up the control to the plugin, the following javascript is required:

$('#MyPropertyName').select()

The plugin includes options for:

  • the number of characters to type before filtering items that contain the search text (as opposed to items that start with the search text)
  • The maximum number of items to display (before a scrollable list is created)
  • The character to use as the separator in a grouped or hierarchical list (refer the images above)
  • the number of characters to type before making an AJAX call

How it works

The download includes a solution containing examples of each helper overload and list type, the full source code including the jQuery plugin and style sheet, and a help file which explains keyboard navigation, options, events raised by the control and HTML className used. The code is (I think) well commented (as an intermittent hobby programmer I learnt the value of that early on), and in any case is too long to reproduce here, but I will cover some key aspects of how it works.

The HTML

The helper generates HTML similar to this (values omitted):

<!--Top level container with data attributes used by the plugin-->
<div class="select" data-displayproperty="" data-idproperty="" 
  data-propertyname="">
  <div class="select-input" style="position:relative;">
    <input autocomplete="off" id="" style="color:transparent;" 
      type="text" value=""/>
    <div style="position:absolute;overflow:initial;
      text-overflow:initial;"></div>
    <button class="drop-button" style="position:absolute;" 
      tabindex="-1" type="button"></button>
    <input name="" type="hidden" value="" disabled="">
    <!--More hidden inputs for each property of the model-->
  </div>
  <div class="select-validation">
    <span class="field-validation-error"></span>
  </div>
  <div class="select-list" style="position:relative;">
    <ul style="position: absolute; display: none; z-index: 1000;">
      <!--The item container (data attributes for each property)-->
      <li data-id="" data-name="" style="margin:0;padding:0;">
        <!--The item itself (contains spans)-->
        <div></div>
        <!--Container for group and hierarchical display -->
        <ul>
          <li><div></div></li>
        </ul>
      </li>
    </ul>
  </div>
</div>

The key elements of the HTML are

  • A hidden input is rendered for each property of the model
  • A corresponding data attribute is rendered for each item in the list (used to update the hidden inputs when a selection is made)
  • Essential style properties are rendered to prevent them being overridden in a style sheet
  • The items are div elements that contain at least one span element for the display text and (optionally) preceding span elements for parent text in a grouped or hierarchical list and a em element to highlight any search text, giving flexibility to style the display using a style sheet

The helper

In each overload, we first get the metadata and the fully qualified name of the property

ModelMetadata metaData = ModelMetadata.FromLambdaExpression(expression, 
  helper.ViewData);
string fieldName = ExpressionHelper.GetExpressionText(expression);

We then determine what type of list needs to be displayed

private static SelectListType GetListType(ModelMetadata metaData, 
  IEnumerable items)
{
  if (metaData.ModelType.IsEnum)
  {
    return SelectListType.EnumList;
  }
  // Other checks for value list and empty list
  ...
  if (items is IDictionary)
  {
    IDictionary dictionary = items as IDictionary;
    Type[] arguments = dictionary.GetType().GetGenericArguments();
    // Checks the key is a string and the value is IEnumerable 
    // and throws exception if not
    ....
    // Get the items type
    Type type = null;
    if (arguments[1].IsGenericType)
    {
      type = arguments[1].GetGenericArguments()[0];
    }
    // Other checks
    ...
    // Now the key bit. The model type must be the same as 
    // (or be assignable from an instance of) the items type 
    if (!metaData.ModelType.IsAssignableFrom(type))
    {
      // Throw exception
    }
    return SelectListType.GroupedList;
  }
  else
  {
    // Get the type and check the ModelType.IsAssignableFrom(type)
    ...
    // Determine if it's a hierarchical list
    metaData = metaData.Properties.FirstOrDefault(m => m.ModelType
      .IsGenericType && m.ModelType
      .GetGenericArguments()[0] == metaData.ModelType);
    if (metaData != null)
    {
      return SelectListType.HierarchialList;
    }
    else
    {
      return SelectListType.FlatList;
    }
  }
}

More checks are then made to validate the id and display properties

// The values must be provided for a complex type
if (metaData.IsComplexType && idProperty == null)
{
  // Throw exception
}
// And it must exist
if (idProperty != null && !metaData.Properties
  .Any(m => m.PropertyName == idProperty))
{
  // Throw exception
}

If the last overload is used, the url is constructed

string root = HttpRuntime.AppDomainAppVirtualPath;
string url = string.Format("{0}{1}/{2}", root, controller, action);

Various methods are then called to construct the individual HTML elements. The one that gave me the most grief was the recursive method to generate the items for a hierarchical list

private static string HierarchialList(IEnumerable items, 
  ModelMetadata selectedItem, string idProperty, string displayProperty)
{
  // Flag selected item has been found
  bool selectionFound = false;
  // Get the property that is the hierarchical property
  string hierarchialProperty = selectedItem.Properties
    .First(m => m.ModelType.IsGenericType && m.ModelType
    .GetGenericArguments()[0] == selectedItem.ModelType).PropertyName;
  // Build the html for each item
  StringBuilder html = new StringBuilder();
  foreach (var item in items)
  {
    // Get the metadata of the item
    ModelMetadata metaData = ModelMetadataProviders.Current
      .GetMetadataForType(() => item, selectedItem.ModelType);
    // Append the list item
    html.Append(HierarchialItem(metaData, selectedItem, idProperty, 
      displayProperty, hierarchialProperty, ref selectionFound));
  }
  // Return the html
  return SelectList(html.ToString());
}

Which uses

private static string HierarchialItem(ModelMetadata item, 
  ModelMetadata selectedItem, string idProperty, string displayProperty, 
  string hierarchialProperty, ref bool selectionFound)
{
  StringBuilder html = new StringBuilder();
  // Build the display text
  TagBuilder text = new TagBuilder("div");
  if (!selectionFound)
  {
    if (IsSelected(item, selectedItem, idProperty))
    {
      selectionFound = true;
      text.AddCssClass("selected");
    }
  }
  text.InnerHtml = GetDisplayText(item, displayProperty);
  html.Append(text.ToString());
  // Build list item
  TagBuilder listItem = new TagBuilder("li");
  foreach (ModelMetadata property in item.Properties)
  {
    if (property.PropertyName == hierarchialProperty)
    {
      StringBuilder innerHtml = new StringBuilder();
      // Flag to indicate if there are child items to display
      bool hasChildren = false;
      IEnumerable children = property.Model as IEnumerable;
      foreach (var child in children)
      {
        // Signal there are child items to display
        hasChildren = true;
        // Get the metadata
        ModelMetadata childItem = ModelMetadataProviders.Current
          .GetMetadataForType(() => child, item.ModelType);
        // Recursive call to build the child items
        innerHtml.Append(HierarchialItem(childItem, selectedItem, 
          idProperty, displayProperty, hierarchialProperty, 
          ref selectionFound));
      }
      if (hasChildren)
      {
        TagBuilder list = new TagBuilder("ul");
        list.InnerHtml = innerHtml.ToString();
        html.Append(list.ToString());
      }
      else
      {
        html.Append(innerHtml.ToString());
      }
    }
    else
    {
      // Add attributes
      string attributeName = string
        .Format("data-{0}", property.PropertyName);
      string attributeValue = string.Format("{0}", property.Model);
      listItem.MergeAttribute(attributeName, attributeValue);
    }
  }
  listItem.InnerHtml = html.ToString();
  // Add essential style properties
  listItem.MergeAttribute("style", "margin:0;padding:0;");
  // Return the html
  return listItem.ToString();
}

Which uses

private static bool IsSelected(ModelMetadata item,
  ModelMetadata selectedItem, string idProperty)
{
  if (item.Model == null || selectedItem.Model == null)
  {
    return false;
  }
  if (idProperty == null)
  {
    return Object.ReferenceEquals(item.Model, selectedItem.Model);
  }
  if (item.IsComplexType)
  {
    return item.Properties.First(m => m.PropertyName == idProperty)
      .Model.Equals(selectedItem.Properties
      .First(m => m.PropertyName == idProperty).Model);
  }
  return item.Model.Equals(selectedItem.Model);
}

to determine if the model matches an item in the list and therefore is the 'selected' item, and

public static string GetDisplayText(ModelMetadata item, string
  displayProperty)
{
  if (displayProperty == null)
  {
    // Use the .ToString() method of the model
    return string.Format("{0}", item.Model);
  }
  else
  {
    return string.Format("{0}", item.Properties
      .FirstOrDefault(m => m.PropertyName == displayProperty).Model);
  }
}

to determine the text to display.

The plugin

The plugin wires it all up. A key function is a recursive method that allows all properties of a complex type to be posted back. It's parameters are the data of the item that is selected (the data-attributes of the items parent li element) and the name of the property. In the first iteration, name will be the name of the property the plugin is attached to (e.g. ContactPerson). If its finds a matching input, the property must be a value type so the input is updated and we exit the function. If no match is found then the attributes key is appended to name (e.g. ContactPerson.ID) and we again search for a matching input name and update it if found. No match is found because ContactPerson contains a complex property Organisation, then the function is called recursively (e.g. to find at match for ContactPerson.Organisation.ID).

select.prototype.updateInputs = function (data, name) {
  var self = this;
    $.each(data, function (attr, value) {
      // Find the matching hidden input ('values' is the jQuery collection 
      // of hidden input elements) and set it's value
      var input = self.values.filter(function () {
        return $(this).attr('name').toLowerCase() === attr.toLowerCase();
      }).val(value);
      if (input.length === 1) {
      // It's a value type so there is only one input
        return;
      }
      // Build the property name
      var propertyName = name + '.' + attr;
      // If the value is an object then we are dealing with complex 
      // properties within complex properties
      if (typeof (value) === 'object') {
        // Recursive call
        self.updateInputs(value, propertyName);
      }
      else {
        // Get and set the matching input (as above)
        input = self.values.filter(function () {
          return $(this).attr('name').toLowerCase() === propertyName
            .toLowerCase();
      }).val(value);
    }
  });
}

And now I understand recursive functions!

Using the code

From the download (Visual Studio 2012, MVC 4.0):

  1. Build the project (the download does not include the packages, obj and bin folders but these will be restored on first build)
  2. Add a reference to both Sandtrap.dll and Sandtrap.Web.dll in your project
  3. Copy the sandtrap-select-v1.0.js (or minified version) and the sandtrap-select-v1.0.css files to your project
  4. Add the namespace to your web.config files
    <system.web>
      <namespaces>
       <add namespace="Sandtrap.Web.Html">
    
  5. In your view, add a reference to the js file

The rest should be clear from examining the examples and help file in the download.

Known issues

  1. I have not been able to get jQuery unobtrusive validation to work. If the property has the [Required] attribute (this is the only validation attribute that makes sense for a complex property) and a @Html.ValidationMessageFor(...) is associated with the control, it will display the validation message correctly if, on postback, the model is null and the view is returned. If an item is then selected there is a hack to find the message and delete it's contents.
  2. If the model contains a property which is a collection, then no inputs are rendered for objects in the collection (I initially included this but in testing one case with a hierarchical structure, it rendered over 400 inputs - I figured that it's cheaper to populate the model with a to call the database than to send and receive that much extra HTML).

Points of Interest

I have been using this control in an intranet application I developed for my office for over 3 months now without any issues. However this was my first attempt at a major HtmlHelper and jQuery plugin and I have no training in programming (all my knowledge comes from CodeProject, Stack Overflow and other sites I stumble across). I have no doubt that there will be scenarios I have not considered and areas where the code or code structure could be improved. Any feedback will be appreciated.

If your curious about the one and only file in the Sandtrap project, both Sandtrap and Sandtrap.Web projects are tiny versions of the real assemblies. My next articles will be on

@Html.TableDisplayFor(MyCollection as IEnumerable)

and

@Html.TableEditorFor(MyCollection as IEnumerable)

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Stephen Muecke

Australia Australia
No Biography provided

Comments and Discussions

 
Questionsource code file link broken PinmemberTridip Bhattacharjee4-May-14 22:19 
AnswerRe: source code file link broken PinmemberStephen Muecke5-May-14 1:01 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Terms of Use | Mobile
Web04 | 2.8.1411023.1 | Last Updated 5 May 2014
Article Copyright 2014 by Stephen Muecke
Everything else Copyright © CodeProject, 1999-2014
Layout: fixed | fluid