How to produce non-sequential prefix collection indices with MVC HTML Editor templates?

别等时光非礼了梦想. 提交于 2019-11-28 12:52:59

While ago I tackled with this problem and ran into a post from S. Sanderson(creator of Knockoutjs) where he described and solved similar problem. I used portions of his code and tried to modify it to suit my needs. I put the code below in some class (exapmle: Helpers.cs), add the namespace in web.config.

    #region CollectionItem helper
    private const string idsToReuseKey = "__htmlPrefixScopeExtensions_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();

        // 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}\" />", collectionName, itemIndex));

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

    public static IDisposable BeginHtmlFieldPrefixScope(this HtmlHelper html, string htmlFieldPrefix)
    {
        return new HtmlFieldPrefixScope(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 HtmlFieldPrefixScope : IDisposable
    {
        private readonly TemplateInfo templateInfo;
        private readonly string previousHtmlFieldPrefix;

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

            previousHtmlFieldPrefix = templateInfo.HtmlFieldPrefix;
            templateInfo.HtmlFieldPrefix = htmlFieldPrefix;
        }

        public void Dispose()
        {
            templateInfo.HtmlFieldPrefix = previousHtmlFieldPrefix;
        }
    }

    #endregion

After you can have EditorTemplate or partial like this

@using (Html.BeginCollectionItem("AnswerChoices"))
{
@Html.HiddenFor(m => m.AnswerChoiceId)
@Html.TextBoxFor(m => m.Name)
}

And enumerate through your list rendering template(partial).

It took me way longer than it should to figure this out. Everyone is working way too hard to do this. The secret sauce is these four lines of code:

        @{
            var index = Guid.NewGuid();
            var prefix = Regex.Match(ViewData.TemplateInfo.HtmlFieldPrefix, @"^(.+)\[\d+\]$").Groups[1].Captures[0].Value;
            //TODO add a ton of error checking and pull this out into a reusable class!!!!
            ViewData.TemplateInfo.HtmlFieldPrefix = prefix + "[" + index + "]";
        }
        <input type="hidden" name="@(prefix).Index" value="@index"/>

Now, what is this doing? We get a new guid, this is our new index to replace the integer one that is automagically assigned. Next we get the get the the default field prefix and we strip off that int index we don't want. After acknowledging we've created some technical debt, we then update the viewdata so that all of the editorfor calls now use that as the new prefix. Finally, we add an input that gets posted back to the model binder specifying the index it should use to bind these fields together.

Where does this magic need to happen? Inside your editor template: /Views/Shared/EditorTemplates/Phone.cshtml

@using TestMVC.Models
@using System.Text.RegularExpressions
@model Phone
    <div class="form-horizontal">
        <hr />
        @{
            var index = Guid.NewGuid();
            var prefix = Regex.Match(ViewData.TemplateInfo.HtmlFieldPrefix, @"^(.+)\[\d+\]$").Groups[1].Captures[0].Value;
            //TODO add a ton of error checking and pull this out into a reusable class!!!!
            ViewData.TemplateInfo.HtmlFieldPrefix = prefix + "[" + index + "]";
        }
        <input type="hidden" name="@(prefix).Index" value="@index"/>
        <div class="form-group">
            @Html.LabelFor(model => model.Number, htmlAttributes: new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                @Html.EditorFor(model => model.Number, new { htmlAttributes = new { @class = "form-control" } })
                @Html.ValidationMessageFor(model => model.Number, "", new { @class = "text-danger" })
            </div>
        </div>

        <div class="form-group">
            @Html.LabelFor(model => model.IsEnabled, htmlAttributes: new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                <div class="checkbox">
                    @Html.EditorFor(model => model.IsEnabled)
                    @Html.ValidationMessageFor(model => model.IsEnabled, "", new { @class = "text-danger" })
                </div>
            </div>
        </div>

        <div class="form-group">
            @Html.LabelFor(model => model.Details, htmlAttributes: new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                @Html.TextAreaFor(model => model.Details, new { htmlAttributes = new { @class = "form-control" } })
                @Html.ValidationMessageFor(model => model.Details, "", new { @class = "text-danger" })
            </div>
        </div>
    </div>

EditorTemplate? What?! How?! Just put it in the directory mentioned above using the object name for the filename. Let MVC convention work its magic. From your main view just add the editor for that IEnumerable property:

<div class="form-group">
@Html.LabelFor(model => model.Phones, htmlAttributes: new { @class = "control-label col-md-2" })
<div class="col-md-10">
    @Html.EditorFor(model => model.Phones, new { htmlAttributes = new { @class = "form-control" } })
</div>
</div>

Now, back in your controller, make sure you update your method signature to accept that ienumerable (Bind include Phones):

        [HttpPost]
    [ValidateAntiForgeryToken]
    public ActionResult Create([Bind(Include = "ContactId,FirstName,LastName,Phones")] Contact contact)
    {
        if (ModelState.IsValid)
        {

            db.Contacts.Add(contact);
            db.SaveChanges();
            //TODO need to update this to save phone numbers
            return RedirectToAction("Index");
        }

        return View(contact);
    }

How do you add and remove them on the page? Add some buttons, bind some JavaScript, add a method to the controller that will return a view for that model. Ajax back to grab it and insert it into the page. I'll let you work out those details, as it's just busy work at this point.

Html.EditorFor is nothing else as a so called Html helper method, which renders input with all apropriate attributes.

The only solution which comes me to mind is to write the own one. It must be pretty simple - 5-10 lines ling. Take a look at this Creating Custom Html Helpers Mvc.

Steve Sanderson has provided a simple implementation that may do what you're looking for. I recently started using it myself; it is not perfect, but it does work. You have to do a little magic-stringing to use his BeginCollectionItem method, unfortunately; I'm trying to workaround that myself.

Another option is to override id attribute like this:

@Html.TextBoxFor(m => m.Name, new { id = @guid })

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!