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.
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
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.
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" })
|
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);
}
|
Refer to my
previous article - [AngularJS]
Dynamic Html (using ngBindHtml or Append).
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
沒有留言:
張貼留言