65.9K
CodeProject 正在变化。 阅读更多。
Home

ASP.NET MVC3 中带有非侵入式验证的动态表格输入

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.85/5 (15投票s)

2013年1月15日

CPOL

9分钟阅读

viewsIcon

90373

downloadIcon

2040

解决 MVC3 项目中的几个问题。

摘要

在我最近的 MVC3 项目中,我需要管理一个两级结构,更准确地说,表单包含一个包含多个字段的表格,用户可以动态地添加和删除行。所有这一切都带有非侵入式验证。

这并不容易 - 在这个项目期间,我遇到了几个问题。本文和示例项目总结了我的发现,当然也包括这些问题的可能解决方案。我不得不承认,我进行了大量的谷歌搜索,并且受到了一些来源的启发。我稍后会提及这些来源。

示例项目尽可能地简化,只强调与主题相关的内容。我们将从乐观的假设开始,即一切都如预期般工作,但我们会遇到我遇到的问题。我们将研究问题,寻找解决方案,并实施其中一个。

请注意,本文基于 ASP.NET MVC3 和原始的 jquery 及插件版本,因此可能不完全适用于其他版本。

示例项目

示例项目是一个面向人力资源人员的 MVC3 应用程序,他们可以在其中输入员工姓名,从列表中选择职位,并添加技能。一项技能包括职称和级别。级别可以从预定义的值中选择。

该应用程序包含一个控制器,一个带有表单的视图 - 就这样。实际上没有持久化或其他任何东西。

如果您运行项目,界面看起来是这样的

第一个版本

上述域由下面的输入模型表示

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; }
    }
}

正如我们所见,模型相当简单,并且上面有几个与验证相关的注解。由于我们希望为职位和技能级别提供下拉列表,我们添加了两个额外的模型类,我们称它们为元模型。两者都将有一个 `static` 属性,该属性将返回要与下拉列表一起显示的值得列表。我不会在它们上面浪费更多时间,因为它们没有什么特别之处。

控制器甚至更简单

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);
        }
    }
}

我们创建一个空的实体,添加一个空的技能来填写,然后显示视图。实体在回发后会像提交时一样显示,因此用户可以编辑它。

让我们来看看视图中一个有趣的部分

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

没错,现在里面没有任何动态的东西,但让我们尝试一下,看看这个部分是否有效。

现在我们确保拥有非侵入式客户端验证所需的一切,因此我们在 *web.config* 中设置了配置,并将所有必需的客户端脚本添加到布局文件中。

问题 #1:缺少验证消息

让我们运行项目,在不输入任何数据的情况下尝试提交。我们期望在每个字段下方看到验证错误消息。

但是,没有!与技能级别对应的空下拉列表下方没有错误消息。让我们查看生成的 HTML 代码,看看两个 `SELECT` 元素之间的区别。

这是职位下拉列表的代码

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

这是技能级别的代码

<select class="skill-level" id="Skills_0__Level" name="Skills[0].Level">

就这样:所有的 `data-val-*` 属性都丢失了!看起来 *SelectExtensions.cs* (参见 原始源代码) 中实现的扩展方法缺少正确检索元数据并为复杂模型生成非侵入式验证属性的功能。

我们可以手动添加必要的属性。由于这些属性包含连字符,我们必须从匿名内联对象切换到 `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!" }
         }) 

好吧,这很棒,而且肯定有效。这相当直接,直到我们决定为属性添加更多约束,或者我们有一个包含几十个下拉列表的模型。好消息是,一位大神与我们分享并实现了缺失的功能(参见 助手 的原始来源)。其中有趣的部分是

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);
//..
}

原始代码使用属性名称来获取验证元数据,但这在所有情况下都不起作用。此代码使用代表模型属性的 lambda 表达式来获取元数据并生成验证属性。谢谢你,*counsellorben*,无论你是谁!

问题 #2:如何使其动态化?

现在我们已经在所有字段上实现了非侵入式验证,我们必须按照我们最初的意图使表格输入动态化。有一些方法可以使用模板,但让我们采取另一种方法:克隆最后一行。由于我们有 jquery,它本身并不复杂

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

看起来很简单——但不起作用,因为这样我们就会克隆行中的所有内容——包括所有字段及其所有属性。让我们看看视图的这部分是如何渲染的

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

显然,我们必须以某种方式处理输入元素的 `id` 和 `name` 属性。我们必须在克隆过程中递增索引。我在网上找到了一个帖子(参见 来源),关于一个类似但更简单的场景。基本思想是使用正则表达式从 `id` 和 `name` 中提取索引,递增它,构建新的属性并将其赋予新创建的元素。就这样了吗?不,因为我们还必须修改验证消息 `SPAN` 元素。而且我们必须改变按钮的功能。让我们看看带有一些注释的 JavaScript 代码

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);
    };

添加了一个简单的行删除代码后,我们就可以尝试一下了

太棒了,它工作得很完美。现在,让我们尝试提交

不,又来了!新行中没有验证消息。让我们检查生成的代码

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

看起来我们做得对,输入和选择元素的 **name** 和 **id** 是正确的,甚至 SPAN 的 `data-valmsg-for` 属性也很好。那问题是什么呢?如果我们深入挖掘一点,我们会发现非侵入式验证插件会跟踪受影响的元素,因此我们新创建的元素不会被考虑在内。现在我们有一个新的问题需要解决

问题 #3:扩展验证

如果我们仔细查看 *jquery.validate.unobtrusive.js* 文件,在最后,我们会看到以下几行代码

$(function () {
        $jQval.unobtrusive.parse(document);
    });

通过一点 jquery 知识,我们可以弄清楚它在做什么:当文档完全加载时,它将启动验证器的 `parse` 方法,该方法“解析指定选择器中的所有 HTML 元素。它查找带有 [data-val=true] 属性值的输入元素,并根据 data-val-* 属性值启用验证”(这是文件本身的注释)。

告诉验证器重新解析文档似乎很明显。但这还不够。在此之前,我们必须从其存储库中删除整个表单。

所以这是我们需要添加到上面的 `addTableRow` JavaScript 函数末尾的代码

// 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);

我们希望我们已经解决了将验证扩展到新创建的行的问题。让我们通过添加一些行并提交来尝试一下。

太棒了!

现在让我们填写一些数据并提交。

是的,我们如期得到了我们的输入!我们感到非常高兴和宽慰。

但是等等!帕斯卡不是生物学家,让我们删除那一行并再次提交。

对不起,这不是英语,但无论如何,我们看到一个很大的异常:Modell.Skills 为 NULL。NULL!!!这怎么可能发生?

问题 #4:索引不连续

我们不放弃,所以让我们调试:我们在 post 处理操作中设置了一个断点

让我们看看我们有什么:填充的模型 `Skills` 属性确实是空的,而请求包含了缺失的参数。现在该怎么办?如果我们尝试删除除第一行以外的其他行,我们会发现,属性会填充删除行之前的行。这里的逻辑是什么?这就是:内置模型绑定器期望数组具有从零开始的连续索引。如果不存在零索引元素,它将被完全忽略。这就是我们的情况。

我们可以做什么?我们可以添加一些客户端代码来在删除行时或在 post 之前重新索引字段。但有一个更好的选择:创建一个自定义模型绑定器。

想法是过滤请求字段,查找属于数组字段的键。然后提取所有索引,循环遍历这些索引和 `Skill` 类的属性,逐个属性构建一个列表。我们可以使其硬编码到该类和列表,但让我们使其更通用。

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

最后,我们必须在 *global.asax.cs* 文件中添加一行代码

ModelBinders.Binders.Add(typeof(List<Skill>), new ListModelBinder<Skill>()); 

看起来我们成功了。所以让我们尝试一下。我们添加行,删除第一行,然后提交。

pfff… 又出现了一个新问题…

问题 #5:下拉列表未显示选定项

嗯,这是所有问题中最神秘的:由内置模型绑定器绑定的 `Skills` 属性与我们的自定义绑定器之间没有可见的区别。值就在那里,我们甚至可以输出它,但 `DropDownList` 没有考虑它。这次我们选择最短的路径:由于 `SelectList` 构造函数有一个用于选定值的附加参数,我们只需将该值传递给它。

@Html.MyDropDownListFor(model => model.Skills[j].Level, 
new SelectList(UDTID.MetaModels.SkillLevel.GetSkillLevels(), "Code", "Description", 
Model.Skills[j].Level), "-- Select --", new {@class = "skill-level"} )

是的,这次我们真的成功了。

看点

我很好奇 MVC4 是否已修复这些错误,所以我将很快进行检查。

结论

实际上,我没有得出任何结论——除了我们永远无法确定某事是完美无缺的。但我很确定我将有机会利用本文中收集和综合的知识。我希望它也能帮助其他开发者。

更新

  • 2014 年 1 月 9 日 - 同事 *selvan* 注意到一个与复选框相关的问题。`CheckBoxFor` 渲染了两个具有相同名称的控件。其中一个始终为 `false`,因此您会得到模型绑定器在单个输入中获取两个值——这无法解析为布尔值。我建议使用一些技巧——比如隐藏的字符串输入和/或手动渲染的复选框。
  • 2014 年 3 月 10 日 - 同事 *machallo* 和 *Piotr Machałowski* 在模型绑定器代码中发现了一个 bug,该 bug 阻止其解析超过十个项目。
© . All rights reserved.