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

Dynamic tabular input with unobstructive validation in ASP.NET MVC3

, 11 Mar 2014
Rate this:
Please Sign up or sign in to vote.
This article summarizes the possible solution of 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:

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:

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 on interesting part of the view:

@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.

First problem: 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:

<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:

<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:

@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:

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!

Second problem: 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:

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

Look 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:

<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:

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:

<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 than? 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:

Third problem: extending validation

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

$(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 validatior, 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:

// 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?

Fourth problem: 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 trough 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.

// 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 baesd 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 propertie with the above value
                s.GetType().GetProperty(prop.Name).SetValue(s, value, null);
            }
            // Add the instance to teh list
            result.Add(s);
        }
        return result;
    }
}

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

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 nr 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.

@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 synthetized in this article. And I hope that it will help other fellow developers too.

Updates

2014.01.09 - 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.

2014.03.10 - 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)

Share

About the Author

Zoltán Zörgő
Technical Lead
Hungary Hungary
No Biography provided

Comments and Discussions

 
QuestionReturned list of skills is shorter then it should be [modified] PinmemberSasha96916-Aug-14 8:49 
GeneralRe: Returned list of skills is shorter then it should be PinmvpZoltán Zörgő18-Aug-14 1:55 
GeneralRe: Returned list of skills is shorter then it should be PinmemberSasha96918-Aug-14 3:00 
QuestionIs it possible to add REMOVE button to the last element of the list? Pinmembermachallo24-Mar-14 1:44 
AnswerRe: Is it possible to add REMOVE button to the last element of the list? PinmvpZoltán Zörgő24-Mar-14 3:37 
GeneralRe: Is it possible to add REMOVE button to the last element of the list? PinmemberPiotr Machałowski24-Mar-14 11:32 
QuestionBeginners for beginners sake Pinmembercsugden17-Mar-14 9:24 
AnswerRe: Beginners for beginners sake PinmvpZoltán Zörgő17-Mar-14 10:12 
QuestionWhy only 10 elements maximum on the list? Where is the error? Pinmembermachallo10-Mar-14 2:22 
AnswerRe: Why only 10 elements maximum on the list? Where is the error? PinmemberPiotr Machałowski10-Mar-14 5:53 
GeneralRe: Why only 10 elements maximum on the list? Where is the error? PinmvpZoltán Zörgő10-Mar-14 10:32 
GeneralRe: Why only 10 elements maximum on the list? Where is the error? PinmemberPiotr Machałowski10-Mar-14 13:17 
Questionjquery/javascript PinmemberMember 106506106-Mar-14 22:33 
AnswerRe: jquery/javascript PinmvpZoltán Zörgő8-Mar-14 9:43 
GeneralRe: jquery/javascript PinmemberMember 102782728-Mar-14 23:24 
GeneralRe: jquery/javascript PinmvpZoltán Zörgő9-Mar-14 10:56 
GeneralRe: jquery/javascript PinmemberMember 1027827210-Mar-14 22:04 
GeneralRe: jquery/javascript PinmemberMember 1027827210-Mar-14 23:18 
GeneralRe: jquery/javascript PinmvpZoltán Zörgő11-Mar-14 8:03 
QuestionI can't download the source code. Pinmemberbowlder59-Feb-14 20:59 
AnswerRe: I can't download the source code. PinmvpZoltán Zörgő14-Feb-14 1:28 
GeneralRe: I can't download the source code. Pinmemberbowlder524-Feb-14 16:24 
QuestionDatetime Picker in Dynamic table Pinmemberselvan from Chennai, Tamil Nadu19-Jan-14 22:56 
AnswerRe: Datetime Picker in Dynamic table PinmvpZoltán Zörgő23-Jan-14 7:49 
QuestionAdding Checkbox Pinmemberselvan from Chennai, Tamil Nadu9-Jan-14 2:48 

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 | Mobile
Web02 | 2.8.140814.1 | Last Updated 11 Mar 2014
Article Copyright 2013 by Zoltán Zörgő
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid