POST to MVC controller IEnumerable nested Model is

2019-05-11 15:21发布

问题:

When I'm trying to get values in Post the values of checkboxes are set to NULL when I don't check then in order (1, 2, 3, etc).

I need to select any of them in no order (i.e. 4, 5).

MODEL:

public class AssignUsersViewModel
{
    [Display(Name = "Check to select")]
    public bool Select { get; set; }

    public int Id { get; set; }

    [Display(Name = "App. Username")]
    public string UserName { get; set; }

    [Required]
    public string GivenName { get; set; }

    [Required]
    public string Surname { get; set; }

    [Display(Name = "Roles")]
    public IList<Roles> Roles { get; set; }
}

public class AssignUsersAddModel
{
    public bool Select { get; set; }

    public int Id { get; set; }

    public IEnumerable<SelectedRoles> selectedRoles { get; set; }
}

public class SelectedRoles
{
    public string Name { get; set; }
}

CSHTML:

@model IList<AspNetIdentity2DRH.Models.AssignUsersViewModel>
@using (Html.BeginForm("UsersAddToApp", "UsersAdmin", FormMethod.Post))
{
    @Html.AntiForgeryToken()
    <table class="table">
        <tr>
            <th>Check for add</th>
            <th>Username</th>
            <th>Givenname</th>
            <th>Surename</th>
            <th>Roles</th>
        </tr>
        @for (int i = 0; i < Model.Count(); i++)
        {
            <tr>
                <td>
                    @Html.CheckBoxFor(x => x[i].Select)
                    @Html.HiddenFor(x => x[i].Id)
                </td>
                <td>
                    @Html.DisplayFor(x => x[i].UserName)
                </td>
                <td>
                    @Html.DisplayFor(x => x[i].GivenName)
                </td>
                <td>
                    @Html.DisplayFor(x => x[i].Surname)
                </td>
                <td>
                    <div class="row">
                        <div class="form-group">
                            @for (int j = 0; j < Model[i].Roles.Count(); j++)
                            { 
                                <div class="col-sm-4">
                                    <input type="checkbox" name="[@i.ToString()].selectedRoles[@j.ToString()].Name" value="@Model[i].Roles[j].Name" class="checkbox-inline" />
                                    @Html.Label(Model[i].Roles[j].Name, new { @class = "control-label", @data_toggle = "tooltip", @data_placement = "top", @data_original_title = Model[i].Roles[j].Description })
                                </div>
                            }
                        </div>
                    </div>
                </td>
            </tr>
        }
    </table>
    <p>
        <input type="submit" value="Add existing user" class="btn btn-primary" />
        <input type="button" value="Cancel" onclick="window.location.href = '@Url.Action("UsersIndex", "UsersAdmin")';" class="btn btn-cancel" />
    </p>
}

CONTROLLER:

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult UsersAddToApp(List<AssignUsersAddModel> model)
{
    if (ModelState.IsValid)
    {
        foreach (AssignUsersAddModel item in model)
        {
            if (item.Select)
            {
                using (DbContextTransaction dbtrans = db.Database.BeginTransaction())
                {
                    try
                    {
                        int appId = (int)Session["ApplicationId"];
                        Users user = UserManager.FindById(item.Id);
                        db.ApplicationUsers.Add(new ApplicationUsers { ApplicationId = appId, UserId = user.Id });
                        db.SaveChanges();
                        foreach (SelectedRoles RolesItem in item.selectedRoles)
                        {
                            int roleId = db.Roles.Where(r => r.Name == RolesItem.Name).Select(r => r.Id).FirstOrDefault();
                            db.UserApplicationRoles.Add(new UserApplicationRoles { ApplicationId = appId, UserId = user.Id, RoleId = roleId });
                            db.SaveChanges();
                        }
                        dbtrans.Commit();
                    }
                    catch (Exception)
                    {
                        dbtrans.Rollback();
                    }
                }

            }
        }
        return RedirectToAction("UsersAddToApp");
    }
    ModelState.AddModelError("", "An error has occurred. Contact system administrator.");
    return RedirectToAction("UsersAddToApp");
}

The problem is when I select checkboxes (all except the first, or the last o one in the middle, the line:

foreach (SelectedRoles RolesItem in item.selectedRoles)

Sends item.selectedRoles is null.

How I could do this right?

回答1:

The DefaultModelBinder will only bind collections where the indexers of the collection items start at zero and are consecutive. You problem is that you are manually creating a checkbox element. Since unchecked checkboxes do not post back, if you uncheck one, then it and any subsequent checkbox values will not be bound to the collection when you submit.

Next your trying to bind a checkbox to a string value. A checkbox has 2 states and is designed to represent a boolean value.

You have not shown you Role (view) model but it should include a boolean property indicating if it has been selected

public class Role
{
  public int ID { get; set; }
  public string Name { get; set; }
  public bool IsSelected { get; set; }
}

Then in the view, use strongly type html helpers so you get correct 2 way model binding

@for (int j = 0; j < Model[i].Roles.Count; j++)
{ 
  @Html.HiddenFor(m => m[i].Roles[j].Name) // or better use the ID property
  @Html.CheckBoxFor(m => m[i].Roles[j].IsSelected)
  @Html.LabelFor(m => m[i].Roles[j].IsSelected, Model[i].Roles[j].Name)
}

Then in the POST method, your collection will be correctly bound and you could access the selected roles using say,

List<string> selectedRoles = model.Roles.Where(r => r.IsSelected);

Note you may also want to include a hidden input for the Role.ID (rather that the Role.Name property) so you do not need to perform database lookups in your POST methods foreach loop.

Side note: Your post method needs to be

public ActionResult UsersAddToApp(List<AssignUsersViewModel> model)

not ListAssignUsersAddModel> model



回答2:

Nice accepted answer - this is an additional note regarding checkbox and binding.

If you look at the output for a CheckBoxFor, you'll see that there are two inputs for field, eg:

@Html.CheckBoxFor(x=>x.Flag)

gives

<input type='checkbox' name='Flag' ..              // value etc based existing value
<input type='hidden' name='Flag' value='false' />  // always false

this is because an unticked checkbox does not get included in the post, so the second hidden field provides the false (unticked) value to the controller.

When you create the input manually, you can also add this hidden field and it will then pass back with false values and the modelbinder should then pick it up as you were originally expecting:

<input type="checkbox" name="[@i.ToString()].selectedRoles[@j.ToString()].Name" value="@Model[i].Roles[j].Name" class="checkbox-inline" />
<input type="hidden" name="[@i.ToString()].selectedRoles[@j.ToString()].Name" value="@Model[i].Roles[j].Name" value='false' />

In the case of the question, I would certainly refactor and use Html helpers as per the accepted answer.

In other cases, the checkboxes may be added dynamically via javascript, so the above hidden input field is the way to go.



回答3:

Well I found that IEnumerable don't work wthout order (great news)

Son I change the name of the checkbox to:

  • name="[@i.ToString()].selectedRoles.Name"

Then in the controller, For every single enumerable item I can get the following

  • HttpContext.Request.Params["[1].selectedRoles.Name"]

Which returns (in comma-separated) a string with the name of selected items.

So far I have no other idea, but it works for me... I only need to restrict commas in the name attribute.