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

在 MVC 上动态添加控件到分层结构

starIconstarIconstarIconstarIconstarIcon

5.00/5 (13投票s)

2012年8月5日

Ms-PL

4分钟阅读

viewsIcon

90093

downloadIcon

3459

如何在 MVC 中动态地向分层结构添加控件。

引言

在 MVC 中构建分层 UI,当你想编辑它并向服务器提交数据时,通常非常困难。根据其复杂程度,将数据发布到服务器可能会非常困难。我将要介绍的解决方案将展示一个如何编辑分层结构的示例,例如

在 UI 中拥有这种结构,要动态添加控件并 POST 数据到服务器是困难的。通常在这种情况下,你需要手动迭代所有 UI 控件并创建你想要 POST 到服务器的对象。此解决方案的目标是自动创建对象(视图的模型),并将其 POST 到服务器,这样我们就不需要进行任何解析。基本上,我们将能够添加项目(例如,类别项目、子类别项目或产品项目),当表单发布到服务器时,模型会自动为我们创建。服务器将接收到如下对象:

解决方案

此示例使用的模型类是:

public class OrderViewModel
{
    public OrderViewModel()
    {
        Categories = new List<CategoryViewModel>();
    }
    public string Name { get; set; }

    public List<CategoryViewModel> Categories { get; set; }
}  

public class CategoryViewModel
{
    public CategoryViewModel()
    {
        Subcategories = new List<SubCategoryViewModel>();
    }
    public string Name { get; set; }
    public List<SubCategoryViewModel> Subcategories { get; set; }
}

public class SubCategoryViewModel
{
    public SubCategoryViewModel()
    {
        Products = new List<ProductViewModel>();
    }
    public string Name { get; set; }
    public List<ProductViewModel> Products { get; set; }
    public string PreviousFieldId { get; set; }
}

public class ProductViewModel
{
    public string Name { get; set; }
    public double Price { get; set; }
    public string PreviousFieldId { get; set; }
}

对于 UI,我们需要为每个模型类创建部分视图。到目前为止,一切都很简单直接,但问题将开始出现,当你处理这些部分视图时。例如:对于类别视图,你会这样做:

<div>
    <div class="editor-label">
        <div>
              Categories
        </div>
    </div>
    <fieldset>
        <div>
           Name:@Html.TextBoxFor(t => t.Name)
        </div>
           @Html.Partial("SubcategoriesPartial", Model.Subcategories)
    </fieldset>
</div>

对于 SubcategoriesPartial 部分视图,你会这样做:

<div>
    <fieldset>
        <div>
            <div class="editor-label">
                <div>
                    Subcategories
                </div>
            </div>
            <div class="editor-label">
                <div>
                    @Html.ActionLink("Add another subcategory...", 
                      "AddSubCategory", null, new { @class = "addSubcategoryItem" })
                    @foreach (var subcategory in Model)
                    {
                        @Html.Partial("SubcategoryPartial", subcategory)
                    }
                </div>
            </div>
        </div>
    </fieldset>
</div>

而 SubcategoryPartial 部分视图是

<fieldset>
        <div>
            <div class="editor-label" style="float: left; width: 70px;">
                Name:
            </div>
            <div style="float: left">
                @Html.TextBoxFor(m => m.Name)
            </div>
        </div>
        <div style="clear: both">
            <div class="editor-label" style="float: left; width: 70px">
                <div>
                    Products:
                </div>
            </div>
            <div style="float: left">
                <fieldset>
                    <div>
                        @Html.ActionLink("Add product...", "AddProduct", null, new { @class = "addProductItem" })
                        @foreach (var prod in @Model.Products)
                        {
                            @Html.Partial("ProductPartial", prod)
                        }
                    </div>
                </fieldset>
            </div>
        </div>
</fieldset>

以此类推,你将完成 UI。不幸的是,这只适用于你想显示数据。当你希望允许用户编辑这些数据(添加更多/编辑类别、子类别或产品)时,这些视图就做得不好。如果你有这些视图,那么当你将表单提交到服务器时,MVC 框架将无法重新创建 OrderViewModel,因为每个控件的 ID 生成不正确。

在这种情况下,为类别和子类别名称生成的输入标签看起来像:

For Category Name:
<input id="Name" type="text" value="category 0" name="Name">
 
For SubCategory Name:
<input id="Name" type="text" value="SubCategory 0" name="Name"> 

正如你所见,无法区分类别名称和子类别名称,因为生成的输入控件名称和 ID 都相同。为了区分它们,每个输入控件都必须有一个合适的 ID,看起来像:

For Category Name:
<input id="Categories_0__Name" type="text" value="category 0" name="Categories[0].Name">
 
For SubCategory Name:
<input id="Categories_0__Subcategories_0__Name" type="text" value="SubCategory 0" name="Categories[0].Subcategories[0].Name"> 

正如你所见,在这种情况下,你可以区分这些输入控件,并且在提交表单到服务器时可以生成模型。这种方法只在你允许编辑现有项目时才好(而不是从这些模型集合中添加或删除项目)。如果你想添加或删除项目,你需要为每个控件生成正确的索引,这很难跟踪每个集合使用的所有索引。

另一种方法是将索引替换为 GUID,并将生成的 GUID 用作集合的索引。使用此最新方法,输入控件看起来像:

For Category:
<input type="hidden" value="8b9309b2-ad7b-45c0-9725-efd1cc56a514" autocomplete="off" name="Categories.index">
<input id="Categories_8b9309b2-ad7b-45c0-9725-efd1cc56a514__Name" type="text" 
  value="category 0" name="Categories[8b9309b2-ad7b-45c0-9725-efd1cc56a514].Name">
 
For SubCategory:
<input type="hidden" value="ecafcc7c-f1e1-4a2c-af3f-fd1b1ff376cc" 
  autocomplete="off" name="Categories[8b9309b2-ad7b-45c0-9725-efd1cc56a514].Subcategories.index">
<input id="Categories_8b9309b2-ad7b-45c0-9725-efd1cc56a514__Subcategories_ecafcc7c-f1e1-4a2c-af3f-fd1b1ff376cc__Name" 
  type="text" value="SubCategory 0" 
  name="Categories[8b9309b2-ad7b-45c0-9725-efd1cc56a514].Subcategories[ecafcc7c-f1e1-4a2c-af3f-fd1b1ff376cc].Name"> 

基本上,你需要为每个生成的 GUID 创建一个隐藏字段,以指定此 ID 的用途,并使用该 ID 作为索引。通过这种方法,你可以添加/编辑/删除项目,并且在将数据提交到服务器时,模型会正确生成。

现在我们需要创建一个 HTML 助手(HTML 扩展方法),它能够生成这些 GUID 并将这些 ID 放在正确的元素上。为此,我们需要修改 `TemplateInfo.HtmlFieldPrefix`,仅针对代码块内的元素(每个集合项内)。

这些助手方法的代码是:

public static class HtmlModelBindingHelperExtensions
{
    private const string IdsToReuseKey = "__HtmlModelBindingHelperExtensions_IdsToReuse_";

    public static IDisposable BeginCollectionItem(this HtmlHelper html, string collectionName)
    {
        var idsToReuse = GetIdsToReuse(html.ViewContext.HttpContext, collectionName);
        string itemIndex = idsToReuse.Count > 0 ? idsToReuse.Dequeue() : Guid.NewGuid().ToString();

        var previousPrefix = html.ViewData.TemplateInfo.HtmlFieldPrefix;
        // autocomplete="off" is needed to work around a very
        // annoying Chrome behaviour whereby it reuses old values after
        // the user clicks "Back", which causes the xyz.index
        // and xyz[...] values to get out of sync.
        html.ViewContext.Writer.WriteLine(string.Format("<input " + 
          "type=\"hidden\" name=\"{0}.index\" autocomplete=\"off\" 
          value=\"{1}\" />", !string.IsNullOrEmpty(previousPrefix) ? 
          string.Concat(previousPrefix.Trim('.'), ".", collectionName) : 
          collectionName, html.Encode(itemIndex)));

        return BeginHtmlFieldPrefix(html, string.Format("{0}[{1}]", collectionName, itemIndex));
    }

    public static IDisposable BeginHtmlFieldPrefix(this HtmlHelper html, string htmlFieldPrefix)
    {
        return new HtmlFieldPrefix(html.ViewData.TemplateInfo, htmlFieldPrefix);
    }

    private static Queue<string> GetIdsToReuse(HttpContextBase httpContext, string collectionName)
    {
        // We need to use the same sequence of IDs following a server-side validation failure,  
        // otherwise the framework won't render the validation error messages next to each item.
        string key = IdsToReuseKey + collectionName;
        var queue = (Queue<string>)httpContext.Items[key];
        if (queue == null)
        {
            httpContext.Items[key] = queue = new Queue<string>();
            var previouslyUsedIds = httpContext.Request[collectionName + ".index"];
            if (!string.IsNullOrEmpty(previouslyUsedIds))
                foreach (string previouslyUsedId in previouslyUsedIds.Split(','))
                    queue.Enqueue(previouslyUsedId);
        }
        return queue;
    }

    private class HtmlFieldPrefix : IDisposable
    {
        private readonly TemplateInfo _templateInfo;
        private readonly string _previousHtmlFieldPrefix;

        public HtmlFieldPrefix(TemplateInfo templateInfo, string htmlFieldPrefix)
        {
            this._templateInfo = templateInfo;

            _previousHtmlFieldPrefix = templateInfo.HtmlFieldPrefix;
            if (!string.IsNullOrEmpty(htmlFieldPrefix))
            {
                templateInfo.HtmlFieldPrefix = string.Format("{0}.{1}", templateInfo.HtmlFieldPrefix,
                                                             htmlFieldPrefix);
            }
        }

        public void Dispose()
        {
            _templateInfo.HtmlFieldPrefix = _previousHtmlFieldPrefix;
        }
    }
} 

你所需要做的就是在构建部分视图时使用这些助手方法。请注意,`HtmlFieldPrefix` 实现 `IDisposable`,每个助手方法都返回一个 `IDisposable` 对象,因为我们只需要为在我们的 `using` 块内执行的代码更改 `HtmlFieldPrefix`。

如何使用这些助手方法的示例是:

For Category items:
@using (@Html.BeginCollectionItem("Categories"))
{
    <div>
        <div>
                <div>
                    Categories
                </div>
            </div>
        <fieldset>
            <div>
                Name:@Html.TextBoxFor(t => t.Name)
            </div>
            @Html.Partial("SubcategoriesPartial", Model.Subcategories)
        </fieldset>
    </div>
}

对于 SubCategories 项目

<fieldset>
    @using (@Html.BeginHtmlFieldPrefix(Model.PreviousFieldId))
    {
        using (@Html.BeginCollectionItem("Subcategories"))
        {
        <div>
            <div style="float: left; width: 70px;">
                Name:
            </div>
            <div style="float: left">
                @Html.TextBoxFor(m => m.Name)
            </div>
        </div>
        <div style="clear: both">
            <div style="float: left; width: 70px">
                <div>
                    Products:
                </div>
            </div>
            <div style="float: left">
                <fieldset>
                    <div id="@Html.ViewData.TemplateInfo.HtmlFieldPrefix.Trim('.')">
                        @Html.ActionLink("Add product...", 
                          "AddProduct", null, new { @class = "addProductItem" })
                        @foreach (var prod in @Model.Products)
                        {
                            @Html.Partial("ProductPartial", prod)
                        }
                    </div>
                </fieldset>
            </div>
        </div>
        }
    }
</fieldset> 

现在我们只需要让 Add 链接正常工作。这非常直接。正如你所见,链接具有 ID 或类属性,因此我们可以附加一个 JavaScript 方法,该方法将调用服务器并将响应追加到正确的 div。执行此操作的代码,用于 Add Category 链接是:

$("#addCategoryItem").click(function () {
    $.ajax({
        url: this.href,
        cache: false,
        success: function (html) {
            $("#div_Categories").append(html);
        },
        error: function (html) {
            alert(html);
        }
    });
    return false;
}); 

对于另外两个链接(Add Sub Category 和 Add Product),我们需要发送父 div 的 ID,因为在控制器中,我们需要将父 ID 设置到模型,以便为添加的项目获得正确的 ID 序列。所以,执行此操作的代码是:

$(".addSubcategoryItem").click(handleNewItemClick);
 
$(".addProductItem").click(handleNewItemClick);

function handleNewItemClick() {        
    var parent = this.parentElement;
    var formData = {
        id: parent.id
    };
    $.ajax({
        type: "POST",
        url: this.href,
        data: JSON.stringify(formData),
        contentType: "application/json; charset=utf-8",
        success: function (data) {
            $(parent).append(data);
        },
        error: function (data) {
            alert(data);
        }
    });
    return false;
} 

就是这样。试试这个解决方案,让我知道你是否有任何问题。

使用代码

源 zip 文件包含 MVC 项目。你只需要解压并用 Visual Studio 打开解决方案。要看到它的效果,你需要在 `OrderManagerController` 中的 `public ActionResult Edit(OrderViewModel viewModel)` 方法([HttpPost])上设置一个断点,以查看你在 UI 上所做的所有更改是否已自动反映在 viewModel 对象中。

参考文献 

最初的方法可以在这里找到,或者在这里找到。我增强了他的方法,使其能够处理分层数据。

历史

  • 2012 年 8 月 5 日:首次发布到 CodeProject
© . All rights reserved.