2016年1月8日 星期五

[MVC5 X AngularJS] Dynamic add or remove PartialView and Post back correct ViewModel

 MVC5    AngularJS    PartialView    EditorTemplate


Introduction


We are designing a new website with MVC5 and AngularJS, and using Scaffold and HTML Helper as templates.

The questions we met so far are as following.
1.  How to bind AngularJS directives or services correctly on the HTML Helpers?
2.  Can we correctly post back the ViewModel to MVC controller after problem 1 is solved?  

The answers are yes.
For the first question, we can write like this. (Reference)

Html.EditorFor(model => model.Fax, new { htmlAttributes = new { @class = "form-control", ng_model = "Fax", ng_init = "Fax='@Model.Fax'" } })


In the second question, it’s easy and fast to post back the ViewModel with HTML Helper on the view. However, we encountered the third problem when we were trying to create dynamic and multiple PartialViews (or EditorTemplates) on the view and posted back them.

So how to create and remove the PartialView (or EditorTemplate) with AngularJS on the view and post back the ViewModel correctly? This article will show the lesson and learned.


Environment

l   Visual Studio 2015 Ent.
l   MVC5
l   AngularJS v1.4.8


▌Implement



What I want to accomplish



l   User cam dynamic add/remove input blocks.
l   The List of objects can post back correctly.


EditorTemplate : The fast but May not the best choice

The first and fast solution is using EditorTemplate.

l   ViewModel

public class VmCustomers
    {
        [DisplayName("公司名稱")]
        public string CompanyName { get; set; }

        public List<VmCustomer> Customers { get; set; }

        public VmCustomers()
        {
            this.Customers = new List<VmCustomer>();
        }
    }

public class VmCustomer
    {
        [DisplayName("ID")]
        public int Id { get; set; }
        [DisplayName("聯絡人")]
        public string InCharge { get; set; }
        [DisplayName("電話號碼")]
        public string Phone { get; set; }
        [DisplayName("傳真號碼")]
        public string Fax { get; set; }

        public bool WillBeSaved { get; set; }

    }



l   View

<div id="CustomerDivCollection">
            <table class="table">
                <thead class="thead-default">
                    <tr>
                        <th>@Html.LabelFor(model => model.Customers.First().InCharge)</th>
                        <th>@Html.LabelFor(model => model.Customers.First().Phone)</th>
                        <th>@Html.LabelFor(model => model.Customers.First().Fax)</th>
                        <th></th>
                    </tr>
                </thead>
                <tbody id="CustomerTrCollection" name="CustomerTrCollection">

@Html.EditorFor(model => Model.Customers)
       
                </tbody>
            </table>
        </div>


        <div class="form-group">
            <div class="col-md-offset-2 col-md-10">
                <input type="button" value="Add" class="btn btn-default" ng-click="AddCustomer()" />
                <input type="submit" value="Create" class="btn btn-default" />
            </div>
        </div>
    </div>


l   EditorTemplate

Create an EditorTemplate in ~\Views\Shared\EditorTemplates\VmCustomers.cshtml

@model JB.Practice.AngularJS.Website.Models.VmCustomer

<tr>
    <td>
        <div class="col-md-10">
            @Html.Raw(Html.EditorFor(model => model.InCharge, new { htmlAttributes = new { @class = "form-control" } }))
            @Html.ValidationMessageFor(model => model.InCharge, "", new { @class = "text-danger" })
        </div>
    </td>
    <td>
        <div class="col-md-10">
            @Html.Raw(Html.EditorFor(model => model.Phone, new { htmlAttributes = new { @class = "form-control" } }))
            @Html.ValidationMessageFor(model => model.Phone, "", new { @class = "text-danger" })
        </div>
    </td>
    <td>
        <div class="col-md-10">
            @Html.Raw(Html.EditorFor(model => model.Fax, new { htmlAttributes = new { @class = "form-control" } }))
            @Html.ValidationMessageFor(model => model.Fax, "", new { @class = "text-danger" })
        </div>
    </td>
</tr>


Okay, so far we can post back List<VmCustomer> in ViewModel with EditorTemplate.
Now we would like to add or remove the input blocks of VmCustomer at front end, that means we will post back uncertain number of List<VmCustomer> to the MVC Controller.

I found that the EditorTemplate may make me losing dynamic control at the front end.
So I finally choose to use PartialView to achieve my goals:
1.  Input blocks can be added or removed at the front end.
2.  Post back the correct ViewModel.



PartialView : Can it correctly post back List of objects?

Okay, post back List<object> by PartialView will not that easy like EditorTemplate. If we want to post back List<object> with PartialView, first we have to know the rules that how the ViewModel of EditorTemplate/PartialView will be bind into Http form data.

There is a very nice article by Phil Haack, “Model Binding To A List”.

The reason why EditorTemplate can easily bind to the form data and post back correctly is that EditorTemplate just names the inputs (i.e. <input>) with different names!

See the page’s source at picture 1. ,you will find the EditorTemplate automatically name the same input element on different Model  with different names of an order.

(Picture 1.)
 

After every input element has different Html name, the List<object> in ViewModel can bind to the form data correctly. I use Fiddler to show the post-back form data of Http request in the following picture 2.

You can see the name of input elements were
Customers[0].InChare
Customers[1].InChare
Customers[2].InChare
Customers[3].InChare

Important !! Notice the following form data sample will just post back the first 2 objects but not all of them cus they don’t have the complete order.

Customers[0].InChare
Customers[1].InChare
Customers[4].InChare
Customers[5].InChare




(Picture 2.)


But PartialView give our inputs the same names, so List<object> won’t bind correct to the form data.




After understanding the problem of PartialView, we will have to do some tricks on the PartialView and make every input elements on PartialView have different names when we have List<object> in our ViewModel.


PartialView : Modify the Html Helper

So the most important thing is that we have to make sure that the input elements on PartialView must have different and sorted names.
Unfortunately, we cannot use Html Helper parameter : htmlFieldName to change the “name” of the Html input element.

The following codes will just change the “Name” but not “name” of Html element.
However the form data will use “name” but not “Name” of the element, so we have to find another way that can change the “name” of Html element.

@Html.EditorFor(model => model.Phone, new { Name = "newName" , @id = "newId" })


One of the solution is using System.Web.Mvc.Raw(string)  to replace the name.

l   Set the name of Html element with Html Helper

@Html.Raw(Html.EditorFor(model => model.Phone, new { htmlAttributes = new { @class = "form-control" }, @id = "Phone" }).ToString().Replace("Phone", "newString"))

That will works fine! We can change the name of input element on PartialView.
Now we will use ViewModel to set an order and different name of the input elements like this.

@Html.Raw(Html.EditorFor(model => model.Phone, new { htmlAttributes = new { @class = "form-control" }, @id = "Phone" }).ToString().Replace("Phone", $"Customers[{Model.Sn}].Phone"))


Notice that “Sn” is not an useful field for our model for the back end. It actually just a serial number which helps the front end can post back the correct complex ViewModel.


I wrote a simple sample to verify the above theory.

l   MVC : Controller

public ActionResult Create()
        {
            var customers = new VmCustomers();
            customers.Customers = new List<VmCustomer>() {
                new VmCustomer {  Sn = 0,InCharge="JB", Phone="1234" },
                new VmCustomer {  Sn = 1,InCharge="Lily", Phone="1234" },
                new VmCustomer {  Sn = 2,InCharge="Leia", Phone="1234" }
            };

            return View(customers);
        }

        [HttpPost]
        public ActionResult Create([Bind(Prefix = "")] VmCustomers customers)
        {
            LogUtility.Logger.Info($"{customers.CompanyName}");
            foreach (var customer in customers.Customers)
            {
                LogUtility.Logger.Info($"聯絡人:{customer.InCharge},電話號碼:{customer.Phone},傳真號碼:{customer.Fax}}");
            }
            return View(customers);
        }


l   MVC : PartialView

@model JB.Practice.AngularJS.Website.Models.VmCustomer


<td>
    <div class="col-md-10">
        @Html.Raw(Html.EditorFor(model => model.InCharge, new { htmlAttributes = new { @class = "form-control", @id = "InCharge", ng_attribute_name = $"InCharge" } }).ToString().Replace("InCharge", $"Customers[{Model.Sn}].InCharge"))
        @Html.ValidationMessageFor(model => model.InCharge, "", new { @class = "text-danger" })
    </div>
</td>
<td>
    <div class="col-md-10">
        @Html.Raw(Html.EditorFor(model => model.Phone, new { htmlAttributes = new { @class = "form-control" }, @id = "Phone", ng_attribute_name = "Phone" }).ToString().Replace("Phone", $"Customers[{Model.Sn}].Phone"))
        @Html.ValidationMessageFor(model => model.Phone, "", new { @class = "text-danger" })
    </div>
</td>
<td>
    <div class="col-md-10">
        @Html.Raw(Html.EditorFor(model => model.Fax, new { htmlAttributes = new { @class = "form-control", @id = "Fax", ng_attribute_name = "Fax" } }).ToString().Replace("Fax", $"Customers[{Model.Sn}].Fax"))
        @Html.ValidationMessageFor(model => model.Fax, "", new { @class = "text-danger" })
    </div>
</td>



l   MVC : View

@model JB.Practice.AngularJS.Website.Models.VmCustomers


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

    <div class="form-horizontal">
        <hr />
        <div class="col-md-10">
            @Html.EditorFor(model => model.CompanyName, new { htmlAttributes = new { @class = "form-control" } })
            @Html.ValidationMessageFor(model => model.CompanyName, "", new { @class = "text-danger" })
        </div>

        <div\>
            <table class="table">
                <thead class="thead-default">
                    <tr>
                        <th>@Html.LabelFor(model => model.Customers.First().InCharge)</th>
                        <th>@Html.LabelFor(model => model.Customers.First().Phone)</th>
                        <th>@Html.LabelFor(model => model.Customers.First().Fax)</th>
                    </tr>
                </thead>
                <tbody id="CustomerTrCollection" name="CustomerTrCollection">

        @for(int i=0; i<Model.Customers.Count;i++)
        {
            <tr>
                @{
                    Html.RenderPartial("_CustomerPartialView", Model.Customers[i]);
                }
            </tr>
        }
            </tbody>
            </table>
        </div>
        <input type="submit" value="Create" class="btn btn-default" />
</div>


l   Fiddler result



l   Back end logger

 


As you can see, now we successfully use PartialView to post back the List<object> of the complex ViewModel.

On the next step, we will use AngularJS to dynamic add or remove the input blocks from PartialView, and of course, keep posting back the correct ViewModel.




Dynamic Html elements on front end with AngularJS

Since we can post back the complex ViewModel with PartialView, all we have to do is implement the add/remove PartialView by ajax on front end.

The most difficult part will be at making sure all the input elements have different and sorted Html names. The “Add” function won’t be a problem, we can put a hidden element to keep the MAX SN so far. When we add a new element, just name the element with (MAX SN + 1).

But how about the “Remove” function? The user may remove any dynamic PartialView and break the order of the elements’ names! This will make your form data like the following, as user remove the third and fourth elements (PartialView), and results in the ViewModel losing the fifth and sixth elements.

Customers[0].InChare
Customers[1].InChare
Customers[4].InChare => Cannot bind to the post back ViewModel
Customers[5].InChare => Cannot bind to the post back ViewModel

The proper way is that we do not REMOVE the html element, but HIDE them which a user want to remove. Post back all of them, and keep the flags that which elements are HIDE by user. Just save the ones that not be hided!

So I would add a new property, WillBeSaved:bool, to my ViewModel as the flag.

public class VmCustomer
{
        [DisplayName("SN")]
        public int Sn { get; set; }
        [DisplayName("聯絡人")]
        public string InCharge { get; set; }
        [DisplayName("電話號碼")]
        public string Phone { get; set; }
        [DisplayName("傳真號碼")]
        public string Fax { get; set; }

        public bool WillBeSaved { get; set; }
}


And then create a Http Get method to return the Html of PartialView for front end ajax.

l   MVC : Controller

public ActionResult AddCustomer(int customerIndex)
{
            var customer = new VmCustomer()
            {
                Sn = customerIndex,
                InCharge = string.Empty,
                Phone = string.Empty,
                Fax = string.Empty,
                WillBeSaved = true
            };

            //render the new customer's listitem and return the result
            return PartialView("_CustomerPartialView", customer);
}


We have dynamic Html now, so you can APPEND/HIDE it on front end.

l   JS

var app =
angular.module('app', [])
.controller('CustomerCreateCtrl', function ($scope, $http, $q, $sce, $compile) {

    $scope.Customers = [];

    $scope.AddCustomer = function () {
       
        var currentIndex = $scope.HiddenCustomerMaxCnt;
        $scope.HiddenCustomerMaxCnt++;

        var trId = "CustomerTr" + currentIndex;

        var preAppend = "<tr id='" + trId + "' ng-show='Customers[" + currentIndex + "].IsShow'>";
        var postAppend = "<td><input type='button' class='btn btn-default' value='Remove' ng-click='HideCustomerTr(" + currentIndex + ")' /></td></tr><br/>";


        var getUrl = "/Customer/AddCustomer?customerIndex=" + currentIndex;
        $http.get(getUrl).success(function (data) {

            var $el = $(preAppend + data + postAppend).appendTo('#CustomerTrCollection');
            $compile($el)($scope);

            $scope.Customers[currentIndex] =
                {
                    Sn : currentIndex,
                    IsShow: true,
                    WillBeSaved : true
                }

        }).error(function (data, status, headers, config) {
            console.log(data, status, headers, config);
        });

    }

    $scope.HideCustomerTr = function (index) {

        $scope.Customers[index].IsShow = false;
        $scope.Customers[index].WillBeSaved = false;

    }
});


l   MVC : PartialView
And don’t forget to put the new properties of ViewModel to your PartialView!

@model JB.Practice.AngularJS.Website.Models.VmCustomer

<td>
    <div class="col-md-10">
        @Html.Raw(Html.EditorFor(model => model.InCharge, new { htmlAttributes = new { @class = "form-control", @id = "InCharge", ng_attribute_name = $"InCharge" } }).ToString().Replace("InCharge", $"Customers[{Model.Sn}].InCharge"))
        @Html.ValidationMessageFor(model => model.InCharge, "", new { @class = "text-danger" })
    </div>
</td>
<td>
    <div class="col-md-10">
        @Html.Raw(Html.EditorFor(model => model.Phone, new { htmlAttributes = new { @class = "form-control" }, @id = "Phone", ng_attribute_name = "Phone" }).ToString().Replace("Phone", $"Customers[{Model.Sn}].Phone"))
        @Html.ValidationMessageFor(model => model.Phone, "", new { @class = "text-danger" })
    </div>
</td>
<td>
    <div class="col-md-10">
        @Html.Raw(Html.EditorFor(model => model.Fax, new { htmlAttributes = new { @class = "form-control", @id = "Fax", ng_attribute_name = "Fax" } }).ToString().Replace("Fax", $"Customers[{Model.Sn}].Fax"))
        @Html.ValidationMessageFor(model => model.Fax, "", new { @class = "text-danger" })
    </div>
    @Html.Raw(Html.EditorFor(model => model.Sn, new { htmlAttributes = new { @class= "hidden",  @id = "Sn", ng_attribute_name = "Sn", ng_model = "Sn", ng_init = "Sn=" + Model.Sn } }).ToString().Replace("Sn", $"Customers[{Model.Sn}].Sn"))
    @Html.Raw(Html.EditorFor(model => model.WillBeSaved, new { htmlAttributes = new { @class = "hidden", @id = "WillBeSaved", ng_attribute_name = "WillBeSaved", ng_model = "WillBeSaved", ng_init = "WillBeSaved=" + Model.WillBeSaved.ToString().ToLower() } }).ToString().Replace("WillBeSaved", $"Customers[{Model.Sn}].WillBeSaved"))
</td>



The final result

Add 5 rows on the page.

 

Remove the 3rd and 4th rows, and then post back it.

 

The logs on the back-end.

 


Reference






沒有留言:

張貼留言