在"MVC批量添加,增加一条记录的同时添加N条集合属性所对应的个体"中,有2个问题待解决:
1、由jquery动态生成了表单元素,但不能实施验证。
2、一旦集合元素不连续,控制器就无法接收到完整的数据。
批量添加或更新,控制器能接收的方式,大致有2种:
Category.Name
Category.Products[0].Name
Category.Products[3].Name
Category.Products[6].Name
或
<input type="hidden" name="CgdModel.Ubis.Index" value="0" />
<input type="text" name="CgdModel.Ubis[0].Fdt"...
<input
type="text" name="CgdModel.Ubis[0].Memo...
<input type="hidden" name="CgdModel.Ubis.Index" value="1" />
<input type="text" name="CgdModel.Ubis[1].Fdt"...
<input
type="text" name="CgdModel.Ubis[1].Memo...
本篇使用第二种方式,并解决上文提到的2个问题。
批量更新的界面为:
□ Model
一个用户,可以有任意多喜欢看的电影。
public class User
{
public int Id { get; set; }
[Required]
public string Name { get; set; }
public IList<Movie> FavouriteMovies { get; set; }
}
public class Movie
{
[Required]
public string Title { get; set; }
public int Rating { get; set; }
}
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, "Courier New", courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
□
HomeController
显示更新界面,接收更新数据,并相应视图添加按钮,通过部分视图添加新行。
public class HomeController : Controller
{
private static User _currentUser;
private static User CurrentUser
{
get
{
if (_currentUser == null)
{
_currentUser = GetFakeUser();
}
return _currentUser;
}
set { _currentUser = value; }
}
private static User GetFakeUser()
{
return new User()
{
Id = 1,
Name = "darren",
FavouriteMovies = new List<Movie>()
{
new Movie(){Title = "movie1"},
new Movie(){Title = "movie2"},
new Movie(){Title = "movie3"}
}
};
}
public ActionResult EditUser()
{
return View(CurrentUser);
}
[HttpPost]
public ActionResult EditUser(User user)
{
if (!ModelState.IsValid)
{
return View(user);
}
CurrentUser = user;
return View(CurrentUser);
}
//响应视图添加按钮,通过部分视图生成一行
public ActionResult MovieEntryRow()
{
return PartialView("MovieEntryEditor");
}
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, "Courier New", courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
□ Home/EditUser.cshtml
其中,每组集合元素,即用户喜欢的电影通过部分视图MovieEntryEditor.cshtml渲染出来。
@using VariableCollection.Models
@model VariableCollection.Models.User
@{
ViewBag.Title = "EditUser";
Layout = "~/Views/Shared/_Layout.cshtml";
}
@using (Html.BeginForm())
{
@Html.ValidationSummary(true)
<fieldset>
<legend>用户及喜欢电影</legend>
@Html.HiddenFor(model => model.Id)
<div class="editor-label">
@Html.LabelFor(model => model.Name)
</div>
<div class="editor-field">
@Html.EditorFor(model => model.Name)
@Html.ValidationMessageFor(model => model.Name)
</div>
</fieldset>
<fieldset>
<legend>我最喜欢的电影</legend>
@if (Model.FavouriteMovies == null || Model.FavouriteMovies.Count == 0)
{
<p>没有喜欢的电影~~</p>
}
else
{
<ul id="movieEditor" style="list-style-type: none">
@foreach (Movie movie in Model.FavouriteMovies)
{
Html.RenderPartial("MovieEntryEditor", movie);
}
</ul>
<a id="addAnother" href="#">添加行</a>
}
</fieldset>
<p>
<input type="submit" value="提交"/>
</p>
}
@section scripts
{
<script type="text/javascript">
$(function() {
$(‘#movieEditor‘).sortable();
$("#addAnother").click(function () {
$.get(‘/Home/MovieEntryRow‘, function (template) {
$("#movieEditor").append(template);
});
});
});
</script>
}
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, "Courier New", courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
□ Layout.cshtml中把jquery
ui和验证相关js引进来
@Scripts.Render("~/bundles/jquery")
@Scripts.Render("~/bundles/jqueryui")
@Scripts.Render("~/bundles/jqueryval")
□ 写一个帮助方法,目的是生成如下格式:
<input autocomplete="off" name="FavouriteMovies.Index" type="hidden" value="6d85a95b-1dee-4175-bfae-73fad6a3763b"
/>
<input class="text-box single-line" name="FavouriteMovies[6d85a95b-1dee-4175-bfae-73fad6a3763b].Title"
type="text" value="Movie 1" />
...
帮助方法除了要生成目标格式,还要考虑:
●
为了保证隐藏域value值的唯一性,每次渲染部分视图MovieEntryEditor.cshtml,让这里的value有一个唯一的GUID字符串。
●
还需要替换ViewData.TemplateInfo的HtmlFieldPrefix属性值为FavouriteMovies[6d85a95b-1dee-4175-bfae-73fad6a3763b]。
using System;
using System.Collections.Generic;
using System.Web;
using System.Web.Mvc;
namespace VariableCollection.Extension
{
public static class CollectionEditingHtmlExtensions
{
/// <summary>
/// 目标是生成如下格式
///<input autocomplete="off" name="FavouriteMovies.Index" type="hidden" value="6d85a95b-1dee-4175-bfae-73fad6a3763b" />
///<label>Title</label>
///<input class="text-box single-line" name="FavouriteMovies[6d85a95b-1dee-4175-bfae-73fad6a3763b].Title" type="text" value="Movie 1" />
///<span class="field-validation-valid"></span>
/// </summary>
/// <typeparam name="TModel"></typeparam>
/// <param name="html"></param>
/// <param name="collectionName">集合属性的名称</param>
/// <returns></returns>
public static IDisposable BeginCollectionItem<TModel>(this HtmlHelper<TModel> html, string collectionName)
{
string itemIndex = Guid.NewGuid().ToString();
//比如,FavouriteMovies[6d85a95b-1dee-4175-bfae-73fad6a3763b]
string collectionItemName = string.Format("{0}[{1}]", collectionName, itemIndex);
TagBuilder indexField = new TagBuilder("input");
indexField.MergeAttributes(new Dictionary<string, string>() {
{ "name", String.Format("{0}.Index", collectionName) }, //name="FavouriteMovies.Index"
{ "value", itemIndex },//value="6d85a95b-1dee-4175-bfae-73fad6a3763b"
{ "type", "hidden" },
{ "autocomplete", "off" }
});
html.ViewContext.Writer.WriteLine(indexField.ToString(TagRenderMode.SelfClosing));
return new CollectionItemNamePrefixScope(html.ViewData.TemplateInfo, collectionItemName);
}
private class CollectionItemNamePrefixScope : IDisposable
{
private readonly TemplateInfo _templateInfo;
private readonly string _previousPrefix;
public CollectionItemNamePrefixScope(TemplateInfo templateInfo, string collectionItemName)
{
this._templateInfo = templateInfo;
_previousPrefix = templateInfo.HtmlFieldPrefix;
templateInfo.HtmlFieldPrefix = collectionItemName;
}
public void Dispose()
{
_templateInfo.HtmlFieldPrefix = _previousPrefix;
}
}
}
}
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, "Courier New", courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
由于BeginCollectionItem()方法返回的类型是实现了IDisposable接口的CollectionItemNamePrefixScope类,所以,我们在部分视图MovieEntryEditor.cshtml中可以使用using语句。
@using VariableCollection.Extension
@model VariableCollection.Models.Movie
<li style="padding-bottom: 15px;">
@using (Html.BeginCollectionItem("FavouriteMovies"))
{
<img src="@Url.Content("~/Content/images/draggable-icon.png")" style="cursor: move" alt=""/>
@Html.LabelFor(model => model.Title)
@Html.EditorFor(model => model.Title)
@Html.ValidationMessageFor(model => model.Title)
@Html.LabelFor(model => model.Rating)
@Html.EditorFor(model => model.Rating)
@Html.ValidationMessageFor(model => model.Rating)
<a href="#" onclick=" $(this).parent().remove(); ">删除行</a>
}
</li>
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, "Courier New", courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
运行,如果不符合验证要求,会报错,似乎看上去不错:
但,如果把_Layout.cshtml中有关客户端异步验证的js引用去掉,即把@Scripts.Render("~/bundles/jqueryval")注释掉,再次运行,居然服务端不再报错:
为什么?这是由User的ModelState状态不一致引起的。先来看下ModelState类:
public class ModelState
{
public
ModelErrorCollection Errors{get;}
public
ValueProviderResult Value{get;set;}
}
可见,ModelState不仅保存这有关Model的一切错误信息,还保存着有ValueProvider提供的表单数据。
在提交表单之前,界面的input大概是这样的:
<input autocomplete="off" name="FavouriteMovies.Index" type="hidden" value="111"
/>
<input class="text-box single-line" name="FavouriteMovies[111].Title" type="text" value="Movie 1"
/>
<input class="text-box single-line" name="FavouriteMovies[111].Rating" type="text" value="Rating 1"
/>
<input autocomplete="off" name="FavouriteMovies.Index" type="hidden" value="222"
/>
<input class="text-box single-line" name="FavouriteMovies[222].Title" type="text" value="Movie 2"
/>
<input class="text-box single-line" name="FavouriteMovies[222].Rating" type="text" value="Rating 2"
/>
<input autocomplete="off" name="FavouriteMovies.Index" type="hidden" value="333"
/>
<input class="text-box single-line" name="FavouriteMovies[333].Title" type="text" value="Movie 3"
/>
<input class="text-box single-line" name="FavouriteMovies[333].Rating" type="text" value="Rating 3"
/>
当提交失败,回到原先视图界面,这时候,所有的集合元素都需要通过MovieEntryEditor.cshtml来渲染,界面的input大概变成这样:
<input autocomplete="off" name="FavouriteMovies.Index" type="hidden" value="777"
/>
<input class="text-box single-line" name="FavouriteMovies[777].Title" type="text" value="Movie 1"
/>
<input class="text-box single-line" name="FavouriteMovies[777].Rating" type="text" value="Rating 1"
/>
<input autocomplete="off" name="FavouriteMovies.Index" type="hidden" value="888"
/>
<input class="text-box single-line" name="FavouriteMovies[888].Title" type="text" value="Movie 2"
/>
<input class="text-box single-line" name="FavouriteMovies[888].Rating" type="text" value="Rating 2"
/>
<input autocomplete="off" name="FavouriteMovies.Index" type="hidden" value="999"
/>
<input class="text-box single-line" name="FavouriteMovies[999].Title" type="text" value="Movie 3"
/>
<input class="text-box single-line" name="FavouriteMovies[999].Rating" type="text" value="Rating 3"
/>
提交前后,ModelState的状态是不一致的,导致服务端验证失败。
为了保证有关User的ModelState的状态一致,大体上应该这样做:
●
对于通过部分视图MovieEntryEditor.cshtml渲染出来的新的表单元素,我们希望BeginCollectionItem()方法为我们生成新的GUID字符串。
●
对于验证不通过,重新由部分视图MovieEntryEditor.cshtml渲染的表单元素,我们希望还是用原先的GUID字符串,以保证ModelState状态一致。
□
对帮助类CollectionEditingHtmlExtensions进行改良
把原先生成字符串的代码:
string itemIndex = Guid.NewGuid().ToString();
改成:
string collectionIndexFieldName = String.Format("{0}.Index",
collectionName);//FavouriteMovies.Index
string itemIndex =
GetCollectionItemIndex(collectionIndexFieldName);
其中,GetCollectionItemIndex()需要根据某种条件排判断:
1、如果渲染新的表单元素,就产生新的GUID字符串
2、如果不是渲染新的表单元素,就使用原先的GUID字符串
改良后的完整代码如下:
using System;
using System.Collections.Generic;
using System.Web;
using System.Web.Mvc;
namespace VariableCollection.Extension
{
public static class CollectionEditingHtmlExtensions
{
public static IDisposable BeginCollectionItem<TModel>(this HtmlHelper<TModel> html, string collectionName)
{
string collectionIndexFieldName = String.Format("{0}.Index", collectionName);//FavouriteMovies.Index
string itemIndex = GetCollectionItemIndex(collectionIndexFieldName);
//比如,FavouriteMovies[6d85a95b-1dee-4175-bfae-73fad6a3763b]
string collectionItemName = string.Format("{0}[{1}]", collectionName, itemIndex);
TagBuilder indexField = new TagBuilder("input");
indexField.MergeAttributes(new Dictionary<string, string>() {
{ "name", String.Format("{0}.Index", collectionName) }, //name="FavouriteMovies.Index"
{ "value", itemIndex },//value="6d85a95b-1dee-4175-bfae-73fad6a3763b"
{ "type", "hidden" },
{ "autocomplete", "off" }
});
html.ViewContext.Writer.WriteLine(indexField.ToString(TagRenderMode.SelfClosing));
return new CollectionItemNamePrefixScope(html.ViewData.TemplateInfo, collectionItemName);
}
private class CollectionItemNamePrefixScope : IDisposable
{
private readonly TemplateInfo _templateInfo;
private readonly string _previousPrefix;
public CollectionItemNamePrefixScope(TemplateInfo templateInfo, string collectionItemName)
{
this._templateInfo = templateInfo;
_previousPrefix = templateInfo.HtmlFieldPrefix;
templateInfo.HtmlFieldPrefix = collectionItemName;
}
public void Dispose()
{
_templateInfo.HtmlFieldPrefix = _previousPrefix;
}
}
/// <summary>
/// 以FavouriteMovies.Index为键,把Guid字符串存放在上下文中
/// 如果是添加进入部分视图,就直接生成一个Guid字符串
/// 如果是更新,为了保持和ModelState的一致,就遍历原先的Guid
/// </summary>
/// <param name="collectionIndexFieldName">FavouriteMovies.Index</param>
/// <returns>返回Guid字符串</returns>
private static string GetCollectionItemIndex(string collectionIndexFieldName)
{
Queue<string> previousIndices = (Queue<string>)HttpContext.Current.Items[collectionIndexFieldName];
if (previousIndices == null)
{
HttpContext.Current.Items[collectionIndexFieldName] = previousIndices = new Queue<string>();
string previousIndicesValues = HttpContext.Current.Request[collectionIndexFieldName];//HttpContext.Current.Request[FavouriteMovies.Index]
if (!string.IsNullOrWhiteSpace(previousIndicesValues))
{
foreach (string index in previousIndicesValues.Split(‘,‘))
{
previousIndices.Enqueue(index);
}
}
}
return previousIndices.Count > 0 ? previousIndices.Dequeue() : Guid.NewGuid().ToString();
}
}
}
.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, "Courier New", courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }
在GetCollectionItemIndex()方法中,首先把隐藏域的name属性值,比如这里的FavouriteMovies.Index作为key,把队列Queue<string>作为value,以键值对的形式保存在上下文的HttpContext.Current.Items中。然后,通过HttpContext.Current.Request[FavouriteMovies.Index]获取所有隐藏域的value值集合赋值给previousIndicesValues变量,并依次存放到队列Queue<string>中。最后,根据previousIndicesValues是否有集合元素,判断MovieEntryEditor.cshtml视图到底是渲染新的表单元素还是原先的表单元素。
如果previousIndicesValues包含集合元素,就说明MovieEntryEditor.cshtml视图需要还原原先的表单元素,每次从队列Queue<string>中"挤出"第一个GUID字符串,直到没有。这样保证了User对应的ModelState状态的一致性。
如果previousIndicesValues中没有集合元素,就说明MovieEntryEditor.cshtml视图需要渲染新的表单元素,就生成一个新的GUID字符串。
把刚才注释掉的客户端异步验证js引用,再注释回来。
验证不通过:
验证通过:
□ 最后
通过本篇的方法:
<input type="hidden" name="FavouriteMovies.Index" value="m"/>
<input name="FavouriteMovies[m].Title" type="text" value="" />
<input name="FavouriteMovies[m].Rating"
type="text" value="" />
<input type="hidden" name="FavouriteMovies.Index" value="n"/>
<input name="FavouriteMovies[n].Title" type="text" value="" />
<input name="FavouriteMovies[n].Rating"
type="text" value="" />
......
即使集合元素不是连续的,控制器也能接收到所有的集合元素,实现批量添加或更新。
通过部分视图渲染每组集合元素,保证了客户端和服务端的验证功能。
□ 参考资料
※ Editing Variable Length Reorderable Collections in ASP.NET MVC –
Part 1: ASP.NET MVC Views