Click here to Skip to main content
15,868,043 members
Articles / Web Development / ASP.NET

Dynamic Tabular Input with Unobstructive Validation in ASP.NET MVC3

Rate me:
Please Sign up or sign in to vote.
4.85/5 (15 votes)
11 Mar 2014CPOL9 min read 82.4K   2K   40   46
Solutions to several issues in an MVC3 project
This article summarizes the possible solution to several issues that appeared during an MVC3 project.

Synopsis

In my recent MVC3 project, I had to manage to let user edit a two-level structure, more precisely the form contained a table of several fields, and the user had the possibility to add and remove rows dynamically. And all this with unobstructive validation.

This was not straightforward - during this project, I encountered several problems. This article and the sample project summarizes my findings and, of course possible solutions to those problems. I had to admit, I have performed exhaustive Googling and I was inspired by some sources found. I will mention these sources later on.

The sample project is as minimalist as possible, emphasizing only what’s regarding the topic. We will start from the optimistic assumption, that everything is working as expected, but we will encounter the problems I have encountered. We will investigate the problems, look for solutions, and implement one of them.

Please note that this article is based on ASP.NET MVC3 and original jquery and plugin versions, thus might not be fully applicable to other versions.

The Sample Project

The sample project is an MVC3 application for HR personnel, where they can enter employee name, select job position from a list and add skills. A skill consists of title and level. The level can be selected from predefined values.

The application contains one single controller, one view with a form - and that’s all. Actually no persistence or anything else is in place.

If you run the project, the interface looks like this:

First Version

The above domain is represented by the input model below:

C#
namespace UDTID.InputModels
{
    public class Employee
    {
        [Required(ErrorMessage = "Enter employee name!")]
        [Display(Name="Employee name")]
        [StringLength(100, ErrorMessage = "The {0} must be at least {2} characters long.", 
                                           MinimumLength = 6)]
        public string Name { get; set; }
        
        [Required(ErrorMessage="Job position is required!")]
        [Display(Name = "Job position")]
        public int JobPosition { get; set; }
 
        public List<Skill> Skills { get; set; }
 
        public Employee()
        {
            Skills = new List<Skill>();
        }
    }
 
    public class Skill
    {
        [Required(ErrorMessage = "Describe skill!")]
        public string Title { get; set; }
 
        [Required(ErrorMessage = "Select skill level!")]
        public string Level { get; set; }
    }
}

As we can see, the model is quite simple, and has several validation related annotations on it. Since we want dropdowns for job position and skill level, we add two additional model classes, let’s call them meta-models. Both will have a static property that will return the list of values to be displayed with the dropdown lists. I won’t waste more time on them, since they have nothing special.

The controller is even simpler:

C#
namespace UDTID.Controllers
{
    public class HomeController : Controller
    {
        public ActionResult Index()
        {
            var employee = new Employee();
            employee.Skills.Insert(0, new Skill());
 
            return View(employee);
        }
 
        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult Index(Employee employee)
        {
            return View(employee);
        }
    }
}

We create an empty entity, add an empty skill to be filled, show the view. The entity is shown after postback just as it was posted, thus user can edit it.

Let’s take a look at an interesting part of the view:

HTML
@using (Html.BeginForm())
{
    @Html.AntiForgeryToken()
        <fieldset>
        <legend>
            Please enter employee data and skills:
        </legend>
            <div class="flow-row">
                    <div class="flow-editor-label">
                        @Html.LabelFor(model => model.Name)
                    </div> 
                    <div class="flow-editor-field">
                        @Html.EditorFor(model => model.Name)
                        @Html.ValidationMessageFor(model => model.Name)
                    </div>
            </div>
            <div class="flow-row">
                    <div class="flow-editor-label">
                        @Html.LabelFor(model => model.JobPosition)
                    </div>
                    <div class="flow-editor-field">
                        @Html.DropDownListFor(
                            model => model.JobPosition,
                            new SelectList(UDTID.MetaModels.JobPosition.GetJobPositions(), 
                                           "Code", "Position"),
                            "-- Select --",
                            new { @class = "skill-level" })
                        @Html.ValidationMessageFor(model => model.JobPosition)
                    </div>
            </div>
    <table id="skills-table">
            <thead>
                <tr>
                    <th style="width:20px;">&nbsp;</th>
                    <th style="width:160px;">Skill</th>
                    <th style="width:150px;">Level</th>
                    <th style="width:32px;">&nbsp;</th>
                </tr>
            </thead>
            <tbody>
 
            @for (var j = 0; j < Model.Skills.Count; j++)
            {
                <tr valign="top">
                    <th><span class="rownumber"></span></th>
                    <td>
                        @Html.TextBoxFor(model => model.Skills[j].Title, 
                                         new { @class = "skill-title" })
                        @Html.ValidationMessageFor(model => model.Skills[j].Title)
                    </td>
                    <td>
                        @Html.DropDownListFor(
                            model => model.Skills[j].Level,
                            new SelectList(UDTID.MetaModels.SkillLevel.GetSkillLevels(), 
                                           "Code", "Description"),
                            "-- Select --",
                            new {@class = "skill-level"}
                            )
                        @Html.ValidationMessageFor(model => model.Skills[j].Level)
                    </td>
                    <td>
                        @if (j < Model.Skills.Count - 1)
                        {
                            <button type="button" class="remove-row" title="Delete row">
                                    &nbsp;</button>
                        }
                        else
                        {
                            <button type="button" class="new-row" title="New row">
                                    &nbsp;</button> 
                        }
                    </td>
                </tr>
            }
            
            </tbody>
        </table>
        
    </fieldset>
        <p>
            <button type="submit" id="submit">Submit</button>
        </p>
}

You are right, there is nothing dynamic in it for now, but let’s try it to see if this part is working or not.

Now we ensure to have all that’s needed for unobstructive client side validation, thus we set the settings in web.config, and add all necessary client side scripts to the layout file.

Problem #1: Missing Validation Message

Let’s run the project, and without entering any data, try to submit. We expect to see validation error messages below every field.

But, no! The empty dropdown corresponding to the skill level has no error message below it. Let’s look at the generated HTML code and see the difference between the two SELECT elements.

This is the code of the job position dropdown:

HTML
<select class="skill-level" data-val="true" 
data-val-number="The field Job position must be a number." 
data-val-required="Job position is required!" id="JobPosition" name="JobPosition">

and this for the skill level:

HTML
<select class="skill-level" id="Skills_0__Level" name="Skills[0].Level">

And there it is: all data-val-* attributes are missing! It seems that the extension method implemented in SelectExtensions.cs (see original source) is missing the feature to properly retrieve metadata and generate unobstructive validation attributes for complex models.

What we could do is to add necessary attributes by hand. Since these attributes contain dashes, we have to switch from anonymous inline object to dictionary:

C#
@Html.DropDownListFor(
    model => model.Skills[j].Level,
        new SelectList(UDTID.MetaModels.SkillLevel.GetSkillLevels(), "Code", "Description"),
        "-- Select --",
        new Dictionary<string,object>() 
         { 
            { "class", "skill-level" }, 
                { "data-val", "true" },
                { "data-val-required", "Select skill level!" }
         }) 

Well, this is great, and it is working for sure. This is quite straightforward until we decide to add more constraints to the property, or we have a model with tens of dropdowns. Good news, that a guy shared with us and implemented the missing features (see original source of the helper). The interesting part of it is the following:

C#
public static MvcHtmlString DdUovFor<TModel, TProperty>
(this HtmlHelper<TModel> htmlHelper, Expression<Func<TModel, TProperty>> expression, 
IEnumerable<SelectListItem> selectList, string optionLabel, 
IDictionary<string, object> htmlAttributes)
{
//..
    ModelMetadata metadata = 
                  ModelMetadata.FromLambdaExpression(expression, htmlHelper.ViewData);
    IDictionary<string, object> validationAttributes = 
                htmlHelper.GetUnobtrusiveValidationAttributes
                (ExpressionHelper.GetExpressionText(expression), metadata);
//..
}

The original code is using the name of the property to get validation metadata, but that is not working in all situations. This code is using the lambda expression representing the model property to get the metadata and to generate the validation attributes. Thank you counsellorben, whoever you are!

Problem #2: How to Make It Dynamic?

Now that we have unobstructive validation working on all fields, we have to make the tabular input dynamic as we originally intended. There are some approaches like using some template, but let’s take an other path: cloning the last row. Since we have jquery, it is not complicated on its own:

C#
function addTableRow(table) {
        var $ttc = $(table).find("tbody tr:last");
        var $tr = $ttc.clone();
        $(table).find("tbody tr:last").after($tr);
    };

Looks so simple – but won’t work, because this way we clone everything in the row – including all fields with all their attributes. Let’s see how this part of the view is rendered:

HTML
<tr valign="top">
    <th><span class="rownumber"></span></th>
    <td>
        <input class="skill-title" data-val="true" 
         data-val-required="Describe skill!" id="Skills_0__Title" name="Skills[0].Title" 
         type="text" value="" />
        <span class="field-validation-valid" data-valmsg-for="Skills[0].Title" 
         data-valmsg-replace="true"></span>
    </td>
    <td>
        <select class="skill-level" data-val="true" 
         data-val-required="Select skill level!" id="Skills_0__Level" name="Skills[0].Level">
            <option value="">-- Select --</option>
            <option value="0">Beginner</option>
            <option value="1">Intermediate</option>
            <option value="2">Expert</option>
            <option value="3">Wizard</option>
        </select>
        <span class="field-validation-valid" data-valmsg-for="Skills[0].Level" 
         data-valmsg-replace="true"></span>
    </td>
    <td>
        <button type="button" class="new-row" title="New row">&nbsp;</button> 
    </td>
</tr>

It is obvious, that we have to handle somehow the id and the name attribute of the input and select element respectively. We have to increment the index during cloning. I found a post on the web (see source) about a similar but simpler scenario. The basic idea is using regular expressions to extract the index in the id and the name, increment it, build the new attribute and give it to the newly created elements. Is this all? No, since we have to alter the validation message SPAN element also. And we have to change the function of the button too. Let’s see the JavaScript code with some comments:

JavaScript
function addTableRow(table) {
        var $ttc = $(table).find("tbody tr:last");
        var $tr = $ttc.clone();
 
        $tr.find("input,select").attr("name", function () {   // find name in the cloned row
            var parts = this.id.match(/(\D+)_(\d+)__(\D+)$/); // extract parts from id, 
                                                              // including index
            return parts[1] + "[" + ++parts[2] + "]." + parts[3]; // build new name
        }).attr("id", function () { // change id also
            var parts = this.id.match(/(\D+)_(\d+)__(\D+)$/);     // extract parts
            return parts[1] + "_" + ++parts[2] + "__" + parts[3]; // build new id
        });
        $tr.find("span[data-valmsg-for]").attr
                ("data-valmsg-for", function () { // find validation message
            var parts = $(this).attr("data-valmsg-for").match
            (/(\D+)\[(\d+)]\.(\D+)$/); // extract parts from the referring attribute
            return parts[1] + "[" + ++parts[2] + "]." + parts[3]; // build new value
        })
        $ttc.find(".new-row").attr("class", "remove-row").attr
        ("title", "Delete row").unbind("click").click(deleteRow); // change button function
        $tr.find(".new-row").click(addRow); // add function to the cloned button
 
        // reset fields in the new row
        $tr.find("select").val(""); 
        $tr.find("input[type=text]").val("");
        
        // add cloned row as last row  
        $(table).find("tbody tr:last").after($tr);
    };

After we add a simple code for row deletion too, we can try it out:

Excellent, it is working like a charm. And now, let’s try to submit:

No, not again! There is no validation message in the new rows. Let’s check the generated code:

HTML
<tr vAlign="top">
  <th>
   <span class="rownumber"></span>
  </th>
  <td>
    <input name="Skills[0].Title" class="skill-title" id="Skills_0__Title" 
     type="text" data-val-required="Describe skill!" data-val="true" value="" />
    <span class="field-validation-valid" data-valmsg-replace="true" 
     data-valmsg-for="Skills[0].Title"></span>
  </td>
  <td>
    <select name="Skills[0].Level" class="skill-level" id="Skills_0__Level" 
     data-val-required="Select skill level!" data-val="true"></select>
    <span class="field-validation-valid" data-valmsg-replace="true" 
     data-valmsg-for="Skills[0].Level"></span>
  </td>
  <td>
    <button title="Delete row" class="remove-row" type="button">&nbsp;</button> 
  </td>
</tr>
 <tr vAlign="top">
  <th>
   <span class="rownumber"></span>
  </th>
  <td>
    <input name="Skills[1].Title" class="skill-title" id="Skills_1__Title" 
     type="text" data-val-required="Describe skill!" data-val="true" value="" />
    <span class="field-validation-valid" data-valmsg-replace="true" 
     data-valmsg-for="Skills[1].Title"></span>
  </td>
  <td>
    <select name="Skills[1].Level" class="skill-level" id="Skills_1__Level" 
     data-val-required="Select skill level!" data-val="true"></select>
    <span class="field-validation-valid" data-valmsg-replace="true" 
     data-valmsg-for="Skills[1].Level"></span>
  </td>
  <td>
    <button title="Delete row" class="remove-row" type="button">&nbsp;</button> 
  </td>
</tr>

It looks like we did it right, the input and select element’s names and ids are correct, and even the SPAN’s data-valmsg-for attributes are good. What’s the problem then? If we dig a little bit deeper, we find out that the unobstructive validation plugin is keeping track of the affected elements, thus our newly created ones won’t be taken into account. Now we have a new problem to solve:

Problem #3: Extending Validation

If we look carefully at the jquery.validate.unobtrusive.js file, at the very end, we see following code lines:

JavaScript
$(function () {
        $jQval.unobtrusive.parse(document);
    });

With a little jquery knowledge, we can figure out what it is doing: when the document is fully loaded, it will initiate the parse method of the validator, which “parses all the HTML elements in the specified selector. It looks for input elements decorated with the [data-val=true] attribute value and enables validation according to the data-val-* attribute values” (this is the comment from the file itself).

It looks obvious to tell the validator to re-parse the document. But that’s not enough. Before doing this, we have to remove the whole form from its repository.

So this is the code we need to add at the end of the addTableRow JavaScript function above:

JavaScript
// Find the affected form
var $form = $tr.closest("FORM");
 
// Unbind existing validation
$form.unbind();
$form.data("validator", null);
 
// Check document for changes
$.validator.unobtrusive.parse(document);

Let’s hope we solved extending the validation to the newly created rows. Let’s try it by adding some rows and submitting.

Excellent!

Now let’s fill some data in, and submit.

Oh yes, we got our input back, as expected! We are really happy and relieved.

But wait! Pascal was no biologist, let’s remove that row and submit again.

Sorry it is not English, but either way, we see a big fat exception: Modell.Skills is NULL. NULL!!! How on Earth can this happen?

Problem #4: Non-Continuous Indexes

We don’t give up, so let’s debug: we put a breakpoint in the post-handling action:

Let’s see what we have: the populated model has the Skills property empty for real, while the request contains the missing parameters. What to do now? If we run some further attempts deleting other than the first row, we will see, that the property is populated with the rows that were before deleted one. What is the logic in this? Here it is: the built-in model binder is expecting the array to have continuous indexes starting from zero. If there is no zero-indexed element, it is totally ignored. This was our case.

What can we do? We could add some code on client side to reindex the fields on row deletion or before post. But there is a better option: let’s create a custom model binder.

The idea is to filter the request fields for the keys belonging to a field of the array. Than extracting all indexes and looping through these and the properties of the Skill class, build a list property by property. We could make it hard-coded to that class and list, but let’s make it more general.

C#
// We will create a geberic class, the type parameter will be element type, Skill in our case  
public class ListModelBinder<t> : IModelBinder
{
    public object BindModel(ControllerContext controllerContext, 
                            ModelBindingContext bindingContext)
    {
        var form = controllerContext.HttpContext.Request.Form;
        // Initialize the result list based on the type
        List<t> result = new List<t>();
        // Initialize regular expression to match array fields in the request, 
        // Skill[i].* in our case
        Regex re = new Regex(string.Format(@"^{0}\[(\d+)]\.*", 
        bindingContext.ModelName), RegexOptions.IgnoreCase | RegexOptions.Compiled);
        // Select all matching keys
        var candidates = form.AllKeys.Where(x => re.IsMatch(x));
        // Query the different indexes using the above regular expression
        var indices = candidates.Select
                      (x => int.Parse(re.Match(x).Groups[1].Value)).Distinct();
        // Get a declared public instance properties of the type parameter, 
        // Title and Level in our case
        var PropInfo = typeof(T).GetProperties(BindingFlags.Public | 
                       BindingFlags.Instance | BindingFlags.DeclaredOnly);
        // Iterate trough all indexes we have found
        foreach (int i in indices)
        {
            // Create an instance of the type parameter, a Skill instance in our case
            T s = Activator.CreateInstance<t>();
            // Iterate trough the properties we have to fill
            foreach (var prop in PropInfo)
            {
                // Get the value from the request
                var value = form[string.Format("{0}[{1}].{2}", 
                            bindingContext.ModelName, i, prop.Name)];
                // Set the instance properties with the above value
                s.GetType().GetProperty(prop.Name).SetValue(s, value, null);
            }
            // Add the instance to the list
            result.Add(s);
        }
        return result;
    }
}

And finally, we have to add a code row to the global.asax.cs file:

C#
ModelBinders.Binders.Add(typeof(List<Skill>), new ListModelBinder<Skill>()); 

It looks we made it. So let’s try it out. We add the rows, delete the first one, and submit.

Pfff… and a new problem arise…

Problem #5: Dropdown Not Showing Selected Item

Well, this is the most mysterious of all: there is no visible difference between the Skills property bound by the built-in model binder and our custom binder. The value is there, we can even output it, but the DropDownList is not taking it into consideration. This time we take the shortest path: since the SelectList constructor has an additional parameter for the selected value, we simply pass the value to it.

C#
@Html.MyDropDownListFor(model => model.Skills[j].Level, 
new SelectList(UDTID.MetaModels.SkillLevel.GetSkillLevels(), "Code", "Description", 
Model.Skills[j].Level), "-- Select --", new {@class = "skill-level"} )

And yes, we really made it this time.

Point of Interest

I am really curious if these bugs have been corrected in MVC4, so I will check it soon.

Conclusions

Actually, I haven’t drawn any conclusion – besides the one, that we can never be sure that something is flawless. But I am pretty sure that I will have the opportunity to take advantage about the knowledge gathered and synthesized in this article. And I hope that it will help other fellow developers too.

Updates

  • 9th January, 2014 - Fellow selvan noticed a problem related to checkboxes. The CheckBoxFor is rendering two controls with the same name. One of them is always false, so you get the model binder gets two values in a single input - which can not be parsed a boolean. I suggest using some hack instead - like hidden string input and/or manually rendered checkbox.
  • 10th March, 2014 - Fellows machallo and Piotr Machałowski have hound a bug in the model binder code, which hindered it to parse more than ten items.

License

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


Written By
Technical Lead
Hungary Hungary
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
QuestionHow can convert this code to net core 2.x? Pin
Pham Dinh Truong13-Jun-19 6:04
professionalPham Dinh Truong13-Jun-19 6:04 
QuestionHow to implement Custom Validation? Pin
Member 1096233421-Dec-14 23:23
Member 1096233421-Dec-14 23:23 
AnswerRe: How to implement Custom Validation? Pin
Zoltán Zörgő22-Dec-14 8:42
Zoltán Zörgő22-Dec-14 8:42 
GeneralRe: How to implement Custom Validation? Pin
Member 1096233422-Dec-14 18:13
Member 1096233422-Dec-14 18:13 
GeneralRe: How to implement Custom Validation? Pin
Zoltán Zörgő22-Dec-14 23:36
Zoltán Zörgő22-Dec-14 23:36 
GeneralRe: How to implement Custom Validation? Pin
Member 1096233423-Dec-14 18:51
Member 1096233423-Dec-14 18:51 
QuestionObject of type 'System.String' cannot be converted to type 'System.Int32'. Pin
zhangtai3-Dec-14 13:01
zhangtai3-Dec-14 13:01 
AnswerRe: Object of type 'System.String' cannot be converted to type 'System.Int32'. Pin
Zoltán Zörgő4-Dec-14 8:45
Zoltán Zörgő4-Dec-14 8:45 
GeneralRe: Object of type 'System.String' cannot be converted to type 'System.Int32'. Pin
zhangtai4-Dec-14 11:13
zhangtai4-Dec-14 11:13 
GeneralGreat artical!! Pin
zhangtai3-Dec-14 12:14
zhangtai3-Dec-14 12:14 
GeneralRe: Great artical!! Pin
Zoltán Zörgő3-Dec-14 12:16
Zoltán Zörgő3-Dec-14 12:16 
QuestionReturned list of skills is shorter then it should be Pin
Sasha96916-Aug-14 8:49
Sasha96916-Aug-14 8:49 
GeneralRe: Returned list of skills is shorter then it should be Pin
Zoltán Zörgő18-Aug-14 1:55
Zoltán Zörgő18-Aug-14 1:55 
GeneralRe: Returned list of skills is shorter then it should be Pin
Sasha96918-Aug-14 3:00
Sasha96918-Aug-14 3:00 
QuestionIs it possible to add REMOVE button to the last element of the list? Pin
machallo24-Mar-14 1:44
machallo24-Mar-14 1:44 
AnswerRe: Is it possible to add REMOVE button to the last element of the list? Pin
Zoltán Zörgő24-Mar-14 3:37
Zoltán Zörgő24-Mar-14 3:37 
GeneralRe: Is it possible to add REMOVE button to the last element of the list? Pin
Piotr Machałowski24-Mar-14 11:32
Piotr Machałowski24-Mar-14 11:32 
QuestionBeginners for beginners sake Pin
csugden17-Mar-14 9:24
professionalcsugden17-Mar-14 9:24 
AnswerRe: Beginners for beginners sake Pin
Zoltán Zörgő17-Mar-14 10:12
Zoltán Zörgő17-Mar-14 10:12 
Please note, that the intended audience of this article is "Intermediate", and it is not intended for beginners. This is exactly because of what you experienced. The methods and approaches described in the article are not basic features of the framework, they rely on deeper knowledge of both the ASP.NET MVC architecture and of jQuery. It was never my intention to explain unobstructive validation, since for that there are several good articles, also here (like this: A Beginner's Tutorial on Validating Model Data and Unobtrusive Client side Validation in ASP.NET MVC[^]), or this one: http://bradwilson.typepad.com/blog/2010/10/mvc3-unobtrusive-validation.html[^].

So if I would go into deeper explanation of all terms I am referring to, I would end up with an article that is boring for more experienced developers.

I suggest yo gather a deeper knowledge about the technologies implied, and come back a little bit later.

"Happy coding...!" Cool | :cool:
QuestionWhy only 10 elements maximum on the list? Where is the error? Pin
machallo10-Mar-14 2:22
machallo10-Mar-14 2:22 
AnswerRe: Why only 10 elements maximum on the list? Where is the error? Pin
Piotr Machałowski10-Mar-14 5:53
Piotr Machałowski10-Mar-14 5:53 
GeneralRe: Why only 10 elements maximum on the list? Where is the error? Pin
Zoltán Zörgő10-Mar-14 10:32
Zoltán Zörgő10-Mar-14 10:32 
GeneralRe: Why only 10 elements maximum on the list? Where is the error? Pin
Piotr Machałowski10-Mar-14 13:17
Piotr Machałowski10-Mar-14 13:17 
Questionjquery/javascript Pin
Member 106506106-Mar-14 22:33
Member 106506106-Mar-14 22:33 
AnswerRe: jquery/javascript Pin
Zoltán Zörgő8-Mar-14 9:43
Zoltán Zörgő8-Mar-14 9:43 

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

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