Nested Collection Models in MVC to Add Multiple Phone Numbers - Part 2

This is Part 2 of the article series. Actually, in this article series we were developing an MVC application that will allow adding multiple phone numbers using Nested Model Concept. User will be able to add or remove (zero or more phone numbers, I need minimum two phone numbers for each employee) phone numbers for any single employee.

In the next post I will show you how to List, Edit, Delete record(s) in such nested (relational) models concept.

Now, let’s start talking from Deleting Phone Number that we left in Part 1.

Step 4: Deleting Phone Number

Before going further here just think how you want your application to behave like you could send the DELETE request to the server immediately after clicking on ‘Remove’ link, but think what when user cancels the form, big problem here because you already deleted records. So, it would be much better to update the value="True" of hidden input control and let the controllers do the rest. And as you know this hidden input control is backed by model property ‘DeletePhone’, so value="True" means DeletePhone="True". Great!

So, to delete any phone numbers we need a link for each generated input boxes. This link should have a JavaScript method call with what to delete (means hiding div.phoneNumber) and what to update (means updating value="True" of hidden field) as parameters.

To build such advanced link (which also makes JS method call) on View page we need to take the power of HtmlHelper class. Add a class inside Model folder with name ‘HtmlHelpers.cs’ and use following codes.

public static class HtmlHelpers
{
    public static IHtmlString RemoveLink(this HtmlHelper htmlHelper, string linkText, string container, string deleteElement)
    {
        var js = string.Format("javascript:removeNestedForm(this,'{0}','{1}');return false;", container, deleteElement);
        TagBuilder tb = new TagBuilder("a");
        tb.Attributes.Add("href", "#");
        tb.Attributes.Add("onclick", js);
        tb.InnerHtml = linkText;
        var tag = tb.ToString(TagRenderMode.Normal);
        return MvcHtmlString.Create(tag);
    }
}

If you don’t know how such HtmlHelper work read it here. In above code we are creating a new helper that has name ‘RemoveLink’ and it will take three parameters linkText (to be displayed on UI), container (div to hide) and deleteElement (div to update).

Now, to use this newly added helper on Phone.cshtml view page, update this view page as given below.

Updated Phone.cshtml

@model NestedModelsMvc.Models.Phone
@using NestedModelsMvc.Models

<div class="phoneNumber">
  <p>
    <label>Phone Number</label>
    @Html.TextBoxFor(x => x.PhoneNumber)
    @Html.HiddenFor(x => x.DeletePhone, new { @class = "mark-for-delete" })
    @Html.RemoveLink("Remove", "div.phoneNumber", "input.mark-for-delete")
  </p>
</div>

Just two changes a new namespace and new helper, take a closer look at the passed parameters and compare it with above helper method.

Notice, in above helper method I am making a call to a JS method removeNestedForm(this, container, deleteElement), we need to implement this too. Add a .js file with name ‘CustomJs.js’ somewhere (preferably inside Scripts folder on project root) in your application and write following code.

CustomJs.js

function removeNestedForm(element, container, deleteElement) {
    $container = $(element).parents(container);
    $container.find(deleteElement).val('True');
    $container.hide();
}

Master peace code, as this will update and hide HTML control/tag at the client. You also need put a reference of this newly added JS file on New.cshtml view page.

Updated New.cshtml

@{
    ViewBag.Title = "New";
}

<script src="~/Scripts/CustomJs.js"></script>

<h2>Add New Employee</h2>

@using (Html.BeginForm()){
    @Html.AntiForgeryToken()

    @Html.EditorForModel()
    <p>
    <button type="submit">Create Employee</button>
    </p>
}

Now, run the application and look at the changes in UI.


Let’s take a closer look to see what’s going on after such updates. You will be able to see the remove link and also able to remove (call it delete if you like) input controls.


In above image, look at the changes when I marked second Phone Number to remove. It marked the div not to display and updated the hidden field parameter to value="True".

When you look at the server side processing of the same actions you will see following things going on.


In the Phones collection, first Phone has a field DeletePhone which has value ‘null’ means don’t do anything with this. But second Phone has a field DeletePhone which has value ‘true’ means controller will take an action to delete it from database table. We are going well.

Step 5: Adding New Phone Numbers

There will be a link and when user clicks the link a new Phone collection will be added with appropriate index based naming.

So, to build such advanced link (which also makes JS method call) on View page we need to take the power of HtmlHelper class. Rather adding another new class inside Model folder I will add following code (by keeping previous codes) inside ‘HtmlHelpers.cs’ and use following codes.

public static class HtmlHelpers
{
    // ... previous codes

    public static IHtmlString AddLink<TModel>(this HtmlHelper<TModel> htmlHelper, string linkText, string containerElement, string counterElement, string collectionProperty, Type nestedType)
    {
        var ticks = DateTime.UtcNow.Ticks;
        var nestedObject = Activator.CreateInstance(nestedType);
        var partial = htmlHelper.EditorFor(x => nestedObject).ToHtmlString().JsEncode();
        partial = partial.Replace("id=\\\"nestedObject", "id=\\\"" + collectionProperty + "_" + ticks + "_");
        partial = partial.Replace("name=\\\"nestedObject", "name=\\\"" + collectionProperty + "[" + ticks + "]");
        var js = string.Format("javascript:addNestedForm('{0}','{1}','{2}','{3}');return false;", containerElement, counterElement, ticks, partial);
        TagBuilder tb = new TagBuilder("a");
        tb.Attributes.Add("href", "#");
        tb.Attributes.Add("onclick", js);
        tb.InnerHtml = linkText;
        var tag = tb.ToString(TagRenderMode.Normal);
        return MvcHtmlString.Create(tag);
    }

    private static string JsEncode(this string s)
    {
        if (string.IsNullOrEmpty(s)) return "";
        int i;
        int len = s.Length;
        StringBuilder sb = new StringBuilder(len + 4);
        string t;
        for (i = 0; i < len; i += 1)
        {
            char c = s[i];
            switch (c)
            {
                case '>':
                case '"':
                case '\\':
                    sb.Append('\\');
                    sb.Append(c);
                    break;
                case '\b':
                    sb.Append("\\b");
                    break;
                case '\t':
                    sb.Append("\\t");
                    break;
                case '\n':
                    break;
                case '\f':
                    sb.Append("\\f");
                    break;
                case '\r':
                    break;
                default:
                    if (c < ' ')
                    {
                        string tmp = new string(c, 1);
                        t = "000" + int.Parse(tmp, System.Globalization.NumberStyles.HexNumber);
                        sb.Append("\\u" + t.Substring(t.Length - 4));
                    }
                    else
                    {
                        sb.Append(c);
                    }
                    break;
            }
        }
        return sb.ToString();
    }
}

You will find two methods in above code, first will generated the helper and second will encode string to make JavaScript safe. We don’t need HTML safe because we already using Razor syntax which is smart enough to deal with such risks.

The helper method AddLink will take few parameters linkText (text to display with link), containerElement (where to place on the DOM), counterElement (for index based control naming), collectionProperty (name of the collection which is ‘Phone’), nestedType (which is Models.Phone).

I need this helper to appear on UI once, so I would add this on Employee.cshtml view page. Here is the updated code.

@model NestedModelsMvc.Models.Employee
@using NestedModelsMvc.Models

@Html.HiddenFor(x => x.EmployeeId)

<p>
  <label>Name</label>
  @Html.TextBoxFor(x => x.Name)
</p>

<p>
  <label>Salary</label>
  @Html.TextBoxFor(x => x.Salary)
</p>

<div id="phoneNumbers">
  @Html.EditorFor(x => x.Phones)
</div>

<p>
  @Html.AddLink("Add More Phone Numbers", "#phoneNumbers", ".phoneNumber", "Phones", typeof(NestedModelsMvc.Models.Phone))
</p>

Just two changes a new namespace and new helper, take a closer look at the passed parameters and compare it with above helper method.

Notice, in above helper method I am making a call to a JS method addNestedForm(containerElement, counterElement, ticks, partial), we need to implement this too. So, rather adding another .js file I will prefer using previous one which is ‘CustomJs.js’ and add following code.

CustomJs.js

//...previous code

function addNestedForm(container, counter, ticks, content) {
    var nextIndex = $(counter).length;
    var pattern = new RegExp(ticks, "gi");
    content = content.replace(pattern, nextIndex);
    $(container).append(content);
}

Master peace code, as this will upend the new collection to the DOM at the client. We already have place the reference of this file on New.cshtml, so nothing to worry.

Step 6: Controller Action Setup

I am using following POST version of New Action method, just to test application.

public class EmployeeController : Controller
{
    public ActionResult New()
    {
        var employee = new Employee();
        employee.CreatePhoneNumbers(2);
        return View(employee);
    }

    [HttpPost]
    public ActionResult New(Employee employee)
    {
        // TODO
        return Redirect("New");
    }

}

Put a breakpoint on line return Redirect(“New”) and hit F5.

Here is my running view, now I can add or remove Phone Numbers:


And when I click on above button ‘Create Employee’, see what’s happening on server side.


One Phone Number out of four is marked for deleting.

Step 7: Inserting Employee and Phones data in Database

To do this, use the code given below which will remove all the phone numbers which is marked for deletion from the collection. After deleting, insertion will take place.

Updated New ActionResult method with following code:

[HttpPost]
public ActionResult New(Employee employee)
{
    CompanyEntities db = new CompanyEntities();

    if (ModelState.IsValid)
    {
        foreach (Phone phone in employee.Phones.ToList())
        {
            if (phone.DeletePhone == true)
            {
                // Delete Phone Numbers which is marked to remove
                employee.Phones.Remove(phone);
            }
        }
        db.Employees.Add(employee);
        db.SaveChanges();
    }
    return Redirect("New");
}

You can see, I’m removing all the phone numbers from the ‘Phones’ collection which is marked for deletion.

And now you all set to run this application which will store your employee data and phone numbers collection in database.

Find Part 3 here.

Source Code: https://github.com/itorian/NestedCollectionMVC

This article is completely inspired from an article posted by jarrettmeyer here: http://jarrettmeyer.com/post/2995732471/nested-collection-models-in-asp-net-mvc-3

Hope this helps.

Comments

  1. Hi Abhimanyu Kumar Vatsa.

    I love your work here.
    I am trying to use the code for adding new phone numbers in step 5, but in the line "var partial = htmlHelper.EditorFor(x => nestedObject).ToHtmlString().JsEncode();" I'm getting an error saying that EditorFor could not be found. Am I missing something here?

    Thanks
    Nick

    ReplyDelete
    Replies
    1. I had the same problem. You should add "using System.Web.Mvc.Html;" and it works.

      Delete
  2. Same problem. "var partial = htmlHelper.EditorFor(x => nestedObject).ToHtmlString().JsEncode();" doesn't compile because of "EditorFor".

    ReplyDelete
  3. Hello Abhi, i try do this but I have the following error:

    'string' does not contain a definition for 'JsEncode' and no extension method 'JsEncode' accepting a first argument of type 'string' could be found (are you missing a using directive or an assembly reference?)

    I'm using:

    using System;
    using System.Web;
    using System.Web.Mvc;
    using System.Web.Mvc.Html;

    Thanks

    ReplyDelete
  4. Very clear explanation. thanks a lot Abhimanyu

    ReplyDelete
  5. I read the first 2 parts of this tutorial and find it very helpful. Thank you for that. I have a question though. I can add multiple phone numbers and save to the database. Once I add Jquery validation, like add [required] to an attribute of employee, if that attribute does not validate, when the screen postback, all my phone numbers that I have entered disappear from the screen. I have to click the add button again for all of them and retype them. Is there a way to track the number of added phone numbers and on postback maintain that information?

    ReplyDelete
  6. How can I get this to work when there are validation errors? Currently all added phones clear when there is a validation error

    ReplyDelete
    Replies
    1. Hello,
      I had the same problem and after a few hours I found the solution.
      For the jQuery Validator unobtrusive know correctly display the span with the message set to the DataNotations properties "data-valmsg-for =" and "for =" also need to be replaced in the Extension method "AddLink" getting as follows:

      public static IHtmlString AddLink(this HtmlHelper htmlHelper, string linkText, string containerElement, string counterElement, string collectionProperty, Type nestedType)
      {
      ...
      partial = partial.Replace("data-valmsg-for=\\\"nestedObject", "data-valmsg-for=\\\"" + collectionProperty + "[" + ticks + "]");
      partial = partial.Replace("for=\\\"nestedObject", "for=\\\"" + collectionProperty + "[" + ticks + "]");
      ...
      }


      But after adding dynamic elements html, you need reapply the jquery validator.
      For this:
      - Access https://xhalent.wordpress.com/2011/01/24/applying-unobtrusive-validation-to-dynamic-content/
      - Copy for your application this extension of unobtrusive jquery and import after it.
      - In js function "addNestedForm", add at the end of it the call to extension, like:

      function addNestedForm(container, counter, ticks, content) {

      ...

      $.validator.unobtrusive.parseDynamicContent('form');
      }

      Good luck!

      Delete

Post a Comment

Popular posts from this blog

Migrating database from ASP.NET Identity to ASP.NET Core Identity

Customize User's Profile in ASP.NET Identity System