ASP NET MVC - Model Create With List of Model

25/11/2014

This post runs through how to create a form for creating a model with a list of child models using MVC 5 and Razor.

I've been rather quiet on the blogging front lately, I've been working on getting my MVC 5 quiz application up and running on Azure, it's here if anyone wants to give it a go.

One thing I wanted to do while working on the site was allow for a model to be created along with a list of "sub" models; for example when creating a question it would be nice for a user to also create the answers on the same page.

Setup

To demo this we need a model which could "own" some collection of other items. I happened on the perfect example today in the Json.NET source:

Rabbit with a pancake on its head.

From here

Yep, that's a rabbit with a pancake on its head.

Let's create our simple classes for this scenario:

namespace SandboxMvc.Pocos
{
    using System;
    using System.Collections.Generic;

    public class Rabbit
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public int IrritationLevel { get; set; }
        public IList<Pancake> Pancakes { get; set; }
    }
    public class Pancake
    {
        public int Id { get; set; }
        public int Thickness { get; set; }
        public bool IsCrepe { get; set; }
    }
}

Controllers

We need two controller actions, first the create action get and post for the rabbit itself and then the partial view action for a pancake.

namespace SandboxMvc.Controllers
{
    using SandboxMvc.Pocos;
    using System.Collections.Generic;
    using System.Web.Mvc;
    public class RabbitController : Controller
    {
        [HttpGet]
        public ActionResult Create()
        {
            Rabbit rabbity = new Rabbit { Pancakes = new List<Pancake>() };
            return View(rabbity);
        }

        [HttpPost]
        public ActionResult Create(Rabbit model)
        {
            if (!ModelState.IsValid) return View(model);
            Session["Rabbit"] = model;
            return RedirectToAction(actionName: "Create");
        }
    }
}

I'm using the session for persistence here because I'm exceedingly lazy.

Views

Let's get Razor to scaffold our Create view first:

@model SandboxMvc.Pocos.Rabbit
@{int index = Model.Pancakes.Count;}
@using (Html.BeginForm())
{
    @Html.AntiForgeryToken()
    <div class="form-horizontal">
        @Html.ValidationSummary(true, "", new { @class = "text-danger" })
        <div class="form-group">
            @Html.LabelFor(model => model.Name, htmlAttributes: new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                @Html.EditorFor(model => model.Name, new { htmlAttributes = new { @class = "form-control" } })
                @Html.ValidationMessageFor(model => model.Name, "", new { @class = "text-danger" })
            </div>
        </div>
        <div class="form-group">
            @Html.LabelFor(model => model.IrritationLevel, htmlAttributes: new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                @Html.EditorFor(model => model.IrritationLevel, new { htmlAttributes = new { @class = "form-control" } })
                @Html.ValidationMessageFor(model => model.IrritationLevel, "", new { @class = "text-danger" })
            </div>
        </div>

        <!-- PANCAKES! -->
        <div id="pancake-group">
            <h4>Pancakes</h4>
        </div>
        @Ajax.ActionLink(linkText: "+", actionName: "_CreateFields", controllerName: "Pancake", routeValues: new { index = index }, ajaxOptions: new AjaxOptions
        {
            InsertionMode = InsertionMode.InsertAfter,
            UpdateTargetId = "pancake-group",
            OnSuccess = "indexIterate"
        },
        htmlAttributes: new { id = "addPancake", @class = "btn btn-default" })

        <div class="form-group">
            <div class="col-md-offset-2 col-md-10">
                <input type="submit" value="Create" class="btn btn-default" />
            </div>
        </div>
    </div>
}

The change we've made is where the comment excitably declares PANCAKES!.

We add a div <div id="pancake-group"> as an update target for our Ajax.

We then include the following Ajax.ActionLink:

@Ajax.ActionLink(linkText: "+", actionName: "_CreateFields", controllerName: "Pancake", 
routeValues: new { index = index }, 
ajaxOptions: new AjaxOptions
    {
        InsertionMode = InsertionMode.InsertAfter,
        UpdateTargetId = "pancake-group",
        OnSuccess = "indexIterate"
    },
    htmlAttributes: new { id = "addPancake", @class = "btn btn-default" })

This simply calls the _CreateFields action on our Pancake controller. It passes the index to add as a route value.

Of particular importance are the OnSuccess value of the AjaxOptions and the id of the htmlAttributes. These allow us to use the following Javascript to increment our link target:

var currentIndex = 0;

function indexIterate() {
    var newHref = $("#addPancake").attr("href");
    var newerHref = newHref.replace(/(?:index=)[0-9]+/i, "index=" + ++currentIndex);
    $("#addPancake").attr("href", newerHref);
};

Also notice the @index set near the top of the view code!

Pancake Controller

Now we add our pancake controller action and View:

public class PancakeController : Controller
{
    public ActionResult _CreateFields(Rabbit model, int? index)
    {
        ViewBag.Index = index ?? 0;
        return PartialView(model);
    }
}

This returns the partial view:

@model SandboxMvc.Pocos.Rabbit
@{
    int i = ViewBag.index ?? 0;
}

<div class="form-group">
    @Html.LabelFor(model => model.Pancakes[i].Thickness, htmlAttributes: new { @class = "control-label col-md-2" })
    <div class="col-md-10">
        @Html.EditorFor(model => model.Pancakes[i].Thickness, new { htmlAttributes = new { @class = "form-control" } })
        @Html.ValidationMessageFor(model => model.Pancakes[i].Thickness, "", new { @class = "text-danger" })
    </div>
</div>
<div class="form-group">
    @Html.LabelFor(model => model.Pancakes[i].IsCrepe, htmlAttributes: new { @class = "control-label col-md-2" })
    <div class="col-md-10">
        @Html.CheckBoxFor(model => model.Pancakes[i].IsCrepe, new { htmlAttributes = new { @class = "form-control" } })
        @Html.ValidationMessageFor(model => model.Pancakes[i].IsCrepe, "", new { @class = "text-danger" })
    </div>
</div>

Compile this and run it and you should be able to create all the pancakes you want on your rabbit.

shows the rabbit being created

Model Validation

When you have to use server side validation of your model, your sub items will disappear on validation if the validation fails.

Resolving this is luckily fairly simple. For your Create view for Rabbit add the following code inside your pancake-group div:

@for (int i = 0; i < index; i++)
{
    @Html.Action(actionName: "_CreateFields", controllerName: "Pancake", routeValues: new { index = i })
}

Conclusion

This tutorial showed how to add a list of models to the create for of another model and have that list submitted on post in addition to server side validation.

The full code for the files featured can be found as follows: