15

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?

David Hollowell - MSFT
  • 1,065
  • 2
  • 9
  • 18
  • You might have a look on http://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) – Mister Henson Sep 11 '12 at 21:33
  • 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 Sep 11 '12 at 23:38

2 Answers2

23

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;
        }
    }
}
Darin Dimitrov
  • 1,023,142
  • 271
  • 3,287
  • 2,928
  • 1
    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. – David Hollowell - MSFT Sep 12 '12 at 14:05
  • @DaveH, but that would be trivially easy to implement. All you have to do is introduce a new view model which has an `IEnumerable` property of the list you are trying to edit, exactly as you have in your question with the `MyModelObject`. – Darin Dimitrov Sep 12 '12 at 14:07
  • 1
    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. – David Hollowell - MSFT Sep 12 '12 at 14:11
  • 1
    Alright, apparently I will have to code it for you step by step. Give me a sec. – Darin Dimitrov Sep 12 '12 at 14:12
  • 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. – Darin Dimitrov Sep 12 '12 at 14:28
  • 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. 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. – David Hollowell - MSFT Sep 12 '12 at 16:47
  • `$('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 – Raman Sinclair Dec 15 '19 at 00:41
0

I was able to take the lesson learned in this blog post http://blog.stevensanderson.com/2010/01/28/editing-a-variable-length-list-aspnet-mvc-2-style/ and apply it to my case where a ModelObject has several properties, many of which are List.

I modified his scripts as appropriate to work with multiple lists within a model, and will blog my solution as soon as I can. It will definitely have to wait until after my current sprint. I'll post the link when I'm done.

David Hollowell - MSFT
  • 1,065
  • 2
  • 9
  • 18