How do I make an Edit Form for an object with a List property in ASP.NET MVC 4 with Razor

13,235

This blog post contains a step by step guide illustrating how to achieve that.


UPDATE:

As requested in the comments section I am illustrating step by step how to adapt the aforementioned article to your scenario.

Model:

public class MyOtherModelObject
{
    public string Name { get; set; }
    public string Description { get; set; }
}

public class MyModelObject
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public List<MyOtherModelObject> OtherModelObjects { get; set; }
}

Controller:

public class HomeController : Controller
{
    public ActionResult Index()
    {
        var model = new MyModelObject
        {
            Id = 1,
            Name = "the model",
            Description = "some desc",
            OtherModelObjects = new[]
            {
                new MyOtherModelObject { Name = "foo", Description = "foo desc" },
                new MyOtherModelObject { Name = "bar", Description = "bar desc" },
            }.ToList()
        };
        return View(model);
    }

    [HttpPost]
    public ActionResult Index(MyModelObject model)
    {
        return Content("Thank you for submitting the form");
    }

    public ActionResult BlankEditorRow()
    {
        return PartialView("EditorRow", new MyOtherModelObject());
    }
}

View (~/Views/Home/Index.cshtml):

@model MyModelObject

@using(Html.BeginForm())
{
    @Html.HiddenFor(x => x.Id)
    <div>
        @Html.LabelFor(x => x.Name)
        @Html.EditorFor(x => x.Name)
    </div>
    <div>
        @Html.LabelFor(x => x.Description)
        @Html.TextBoxFor(x => x.Description)
    </div>
    <hr/>
    <div id="editorRows">
        @foreach (var item in Model.OtherModelObjects)
        {
            @Html.Partial("EditorRow", item);
        }
    </div>
    @Html.ActionLink("Add another...", "BlankEditorRow", null, new { id = "addItem" })

    <input type="submit" value="Finished" />
}

Partial (~/Views/Home/EditorRow.cshtml):

@model MyOtherModelObject

<div class="editorRow">
    @using (Html.BeginCollectionItem("OtherModelObjects"))
    {
        <div>
            @Html.LabelFor(x => x.Name)
            @Html.EditorFor(x => x.Name)
        </div>
        <div>
            @Html.LabelFor(x => x.Description)
            @Html.EditorFor(x => x.Description)
        </div>
        <a href="#" class="deleteRow">delete</a>
    }
</div>

Script:

$('#addItem').click(function () {
    $.ajax({
        url: this.href,
        cache: false,
        success: function (html) {
            $('#editorRows').append(html);
        }
    });
    return false;
});

$('a.deleteRow').live('click', function () {
    $(this).parents('div.editorRow:first').remove();
    return false;
});

Remark: the BeginCollectionItem custom helper is taken from the same article I've linked to, but I am providing it here for completeness sake of the answer:

public static class HtmlPrefixScopeExtensions
{
    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, html.Encode(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;
        }
    }
}
Share:
13,235

Related videos on Youtube

David Hollowell - MSFT
Author by

David Hollowell - MSFT

I work as a Support Escalation Engineer at Microsoft Corporation. Posts here are my opinions and not necessarily my employers. Posts are provided "AS IS" with no warranties, and confer no rights.

Updated on September 15, 2022

Comments

  • David Hollowell - MSFT
    David Hollowell - MSFT over 1 year

    I have an Edit page for my MVC application, using Razor.

    I have a Model like:

    public class MyModelObject
    {
        public int Id { get; set; }
    
        public string Name { get; set; }
    
        public string Description { get; set; }
    
        public List<MyOtherModelObject> OtherModelObjects { get; set; }
    }
    

    And MyOtherModelObject looks like:

    public class MyOtherModelObject
    {
        public string Name { get; set; }
    
        public string Description { get; set; }
    }
    

    I am making Edit page for MyModelObject. I need a way to add space to the form on the Edit page for MyModelObject for the user to create/add as many MyOtherModelObject instances as the user wishes to the List of OtherModelObjects.

    I'm thinking the user can hit a button, that will do ajax to another action which returns a PartialView of form elements (with no form tag since this is intended to part of the form on my edit page). When the user has added all the MyOtherModelObjects they want and filled out the data, they should be able to save their edits to the existing MyModelObject, that will HttpPost to the Edit action and hopefully all the MyOtherModelObjects will be in the correct list.

    I also need the user to be able to re-order the items once they've added them.

    Does anyone know how to make this work? Have sample project, or online sample walkthrough with this solution implemented?

    • Mister Henson
      Mister Henson over 11 years
      You might have a look on haacked.com/archive/2008/10/23/model-binding-to-a-list.aspx (though it's whether the Razor syntax, nor MVC4 it possibly could give an idea)
    • David Hollowell - MSFT
      David Hollowell - MSFT over 11 years
      At first glance. That looks like it'll work for a list, but will it work for a List that's part of another object, and will it be able to bind the Model?
  • David Hollowell - MSFT
    David Hollowell - MSFT over 11 years
    Not exactly. It discusses how to do a variable length list of objects, but it does not discuss how to do it for a variable length list of objects that are part of another object.
  • Darin Dimitrov
    Darin Dimitrov over 11 years
    @DaveH, but that would be trivially easy to implement. All you have to do is introduce a new view model which has an IEnumerable<T> property of the list you are trying to edit, exactly as you have in your question with the MyModelObject.
  • David Hollowell - MSFT
    David Hollowell - MSFT over 11 years
    Trivially easy you say? And how will all this bind back to the parent object MyModelObject. There's some big part of the answer left to solve, which may involve re-writing MVC's default ModelBind behavior. Are there security concerns with doing this. I'm working through this sample, and found it on several other forum posts for solving list of single object, but haven't quite seen the sample I gave solved efficiently yet.
  • Darin Dimitrov
    Darin Dimitrov over 11 years
    Alright, apparently I will have to code it for you step by step. Give me a sec.
  • Darin Dimitrov
    Darin Dimitrov over 11 years
    Please see my updated answer. As you can see the code is very close to the one shown in the article. There no model binder rewrites necessary :-) All you had to do is plug your model. Next time please do a little more efforts.
  • David Hollowell - MSFT
    David Hollowell - MSFT over 11 years
    I just saw your updated answer. I was in fact able to adapt that article to the scenario, even when the model has several List<SomeOtherModelObject>. I didn't even have to rewrite the ModelBinder. All that's left is rearranging the order of the entities in the lists. Thanks man, you got me pointed in the right direction. I haven't tested your solution yet, but I really appreciate the link to article and encouragement to try the method.
  • Arsinclair
    Arsinclair over 4 years
    $('a.deleteRow').live() The live() method was deprecated in jQuery version 1.7, and removed in version 1.9. Use the on() method instead. — docs