2

I am working on a project to help students and advisers select the best courses for the next semester, using ASP.NET MVC 5. The first step is for the student to select the courses he has already taken from a list. The controller which displays the list is:

public ActionResult AddCourseVM (int? id)
    {
        Student student = db.Students.Find(id);
        // List<BaseCourse> potentialCourses = student.StudentConcentration.RequiredCourses.ToList();
        List<BaseCourse> potentialCourses = db.BaseCourses.ToList();
        AddCourseViewModel vModel = new AddCourseViewModel(student, potentialCourses);

        List<Course> listCourses = new List<Course>();

        foreach(BaseCourse baseC in potentialCourses)
        {
            Course c = new Course();
            c.BaseCourse = baseC;
            c.Student = student;
            listCourses.Add(c);
        }

        vModel.PossibleCourses = listCourses;

        return View("AddCourseVM", vModel);
    }

The ViewModel is:

public class AddCourseViewModel
{
    public Student Student { get; set; }
    public List<BaseCourse> AvailCourses { get; set; }
    public List<Course> PossibleCourses { get; set; }

    public AddCourseViewModel(Student s, List<BaseCourse> c)
    {
        Student = s;
        AvailCourses = c;
        PossibleCourses = new List<Course>();
    }

    public AddCourseViewModel()
    {
        Student = new Student();
        AvailCourses = new List<BaseCourse>();
        PossibleCourses = new List<Course>();
    }
}

The Course objects are specific instances of a course (for a given student, in a certain semester, etc), the BaseCourse objects are the individual courses from the course catalog.

I am displaying the possible courses in a list using this view:

    @model CMPSAdvising.ViewModels.AddCourseViewModel
@{
    ViewBag.Title = "AddCourseVM";
}

<h2>Add Courses</h2>

<div>
    <p>Name: @Model.Student.FirstName @Model.Student.LastName</p>
    <p>W#: @Model.Student.WNumber</p>
</div>

<div>
        <p>Select the Courses You Have Taken</p>
</div>
<div>
    @using (Html.BeginForm("AddCourseVM","Students"))
    {
        @Html.AntiForgeryToken();
        <div>
            <table class="table table-bordered">
                <tr>
                    <th>Course</th>
                    <th>Department</th>
                    <th>Number</th>
                    <th>Check if Taken</th>
                    <th>Semester</th>
                    <th>Grade</th>
                </tr>
                @foreach (var course in Model.PossibleCourses)
                {
                    <tr>
                        <td>@course.BaseCourse.Name</td>
                        <td>@course.BaseCourse.CourseNumber</td>
                        <td>@course.BaseCourse.CourseNumber</td>
                        <td>@Html.CheckBoxFor(s => course.Selected)</td>
                        <td>@Html.TextBoxFor(m => course.Semester)</td>
                        <td>@Html.TextBoxFor(g => course.Grade)</td>
                    </tr>
                }
            </table>
            <input type="submit" value="Save Classes Taken" class="btn btn-default" />
        </div>
    }
</div>

And finally the controller that receives the POST when the user hits the button:

[HttpPost]
    [ValidateAntiForgeryToken]
    public ActionResult AddCourseVM (AddCourseViewModel vModel)
    {
        Student stu = vModel.Student;
        foreach (Course c in vModel.PossibleCourses)
        {
            if (c.Selected)
            {
                stu.CoursesTaken.Add(c);
            }
        }

        db.Entry(stu).State = EntityState.Modified;
        db.SaveChanges();

        return RedirectToAction("ListTakenCourses", new { id = stu.ID });
    }

My problem is that the AddCourseViewModel object (vModel) is coming back null. I would like to get the ViewModel back from the web page as an object, or at least get the list of courses that were checked and the student's ID.

danludwig
  • 46,965
  • 25
  • 159
  • 237
jchancey
  • 25
  • 1
  • 5
  • Your `foreach` in the cshtml is causing razor to output `name` attributes in the HTML generated by the `CheckBoxFor`, `TextBoxFor` that do not match with the `AddCourseViewModel ` action argument. The default model binder can't figure out how those input names should be parsed into the action argument model. Take a look at the actual HTML generated by the razor output and compare the form element name attributes to the action argument class' property names. – danludwig Feb 28 '15 at 19:55
  • Ok I looked at the html and they all have the name="course.Selected". If I create unique names for each checkbox using the particular course's ID for example, how can I collect these and send them back to the controller? Or is there a better way of doing this that I am missing? – jchancey Feb 28 '15 at 20:01
  • The nuts and bolts of what you need to know can be found here: http://haacked.com/archive/2008/10/23/model-binding-to-a-list.aspx/ – danludwig Feb 28 '15 at 20:14
  • Also notice that the attribute names correspond to the name of the variable in the `foreach` loop. If you changed that to `@foreach (var pCrs in Model.PossibleCourses)`, you would see the attribute names change to `name="pCrs.Selected"`. You may need to try something like BeginCollectionItem to help: https://www.nuget.org/packages/BeginCollectionItem/ – danludwig Feb 28 '15 at 20:21

1 Answers1

2

I believe there is an app for that called the BeginCollectionItem HtmlHelper. It is discussed briefly with references here, and is based on a blog article written by Steve Sanderson a few years ago.

The problem is that model binding with form collections is not quite the same as model binding with scalar inputs in MVC. Your collection needs an indexer, as discussed here. If it is not binding as expected, examine the name attributes of the form inputs that get rendered, and compare them to the property names and structures within the post model (action argument) class.

From the looks of it, HTML output that looked more like this should cause the action argument to be not null:

<tr>
    <td>HTTP 101</td>
    <td>HTP-101</td>
    <td>HTP-101</td>
    <td><input type="checkbox" name="PossibleCourses[0].Selected" /></td>
    <td><input type="text" name="PossibleCourses[0].Semester"></td>
    <td><input type="text" name="PossibleCourses[0].Grade"></td>
</tr>
<tr>
    <td>MVC 101</td>
    <td>MVC-101</td>
    <td>MVC-101</td>
    <td><input type="checkbox" name="PossibleCourses[1].Selected" /></td>
    <td><input type="text" name="PossibleCourses[1].Semester"></td>
    <td><input type="text" name="PossibleCourses[1].Grade"></td>
</tr>

...and so on, which the following razor should output:

@for (var i = 0; i <= Model.PossibleCourses.Count; i++)
{
    var course = Model.PossibleCourses[i];
    <tr>
        <td>@course.BaseCourse.Name</td>
        <td>@course.BaseCourse.CourseNumber</td>
        <td>@course.BaseCourse.CourseNumber</td>
        <td>@Html.CheckBox(string.Format("PossibleCourses[{0}].Selected", i),
            course.Selected)</td>
        <td>@Html.TextBox(string.Format("PossibleCourses[{0}].Semester", i),
            course.Semester)</td>
        <td>@Html.TextBox(string.Format("PossibleCourses[{0}].Grade", i),
            course.Grade)</td>
    </tr>
}

Note how the name attributes of the form input elements correspond to the name of the indexable (List<Course>) property in your action argument Model, and the indexed (Course) property names wrapped inside the collection. This is one way to help the model binder figure out how to populate the action argument class instance with data, by making the input name attributes match the method argument property names.

You could also use a GUID (or any string for that matter) to serve as an indexer, which is what BeginItemCollection does internally. The following should also help the model binder be able to populate the action argument so that it does not come in as null to the action method:

@foreach (var course in Model.PossibleCourses)
{
    var indexer = Guid.NewGuid(); // or possibly course.CourseId
    <tr>
        <td>@course.BaseCourse.Name</td>
        <td>@course.BaseCourse.CourseNumber</td>
        <td>@course.BaseCourse.CourseNumber</td>
        <td>@Html.Hidden("PossibleCourses.index", indexer)
            @Html.CheckBox(string.Format("PossibleCourses[{0}].Selected", indexer),
            course.Selected)</td>
        <td>@Html.TextBox(string.Format("PossibleCourses[{0}].Semester", indexer),
            course.Semester)</td>
        <td>@Html.TextBox(string.Format("PossibleCourses[{0}].Grade", indexer),
            course.Grade)</td>
    </tr>
}

All that matters is each group of form elements which correspond to a collection item in the action argument class must share the same indexer, and the indexer must be different from other groups of form elements that correspond to a different collection item in the action argument class. A sample of this solution can be understood by reading this question and its answer.

Community
  • 1
  • 1
danludwig
  • 46,965
  • 25
  • 159
  • 237
  • I tried the method suggested (using a for loop to index the checkboxes), and it worked to get the list of courses back into the view model, and the view model to the controller. I am still having a few problems with saving the data and getting the names. etc into the right columns in the database, but I consider the main point of this question answered. I tried to upvote your answer but it says I need 15 reputation in order to upvote. Thanks for your help, when I figure out exactly what my new problem is I may return to this question for more information. – jchancey Feb 28 '15 at 22:03
  • The `for` loop should be just `@for (var i = 0; i <= Model.PossibleCourses.Count; i++) { @Html.TextBoxFor(m => m.PossibleCourses[i].Semester) }` –  Feb 28 '15 at 22:21