Search code examples
c#razorasp.net-core-mvcasp.net-core-2.2

Razor code producing unexpected result when looping through a list of checkboxes


What possible reason could there be for this razor code:

// ShowSelectedMembers.cshtml
@model MyApp.Models.MembersViewModel

@for (int m = 0; m < Model.Members.Count; m++)
{
    <input type="hidden" asp-for="@Model.Members[m].Id" />
    <input type="hidden" asp-for="@Model.Members[m].FirstName" />
    <input type="hidden" asp-for="@Model.Members[m].LastName" />
    <span>[debug-info: m = @m, id = @Model.Members[m].Id]</span>
    <span>@Model.Members[m].LastName</span>,
    <span>@Model.Members[m].FirstName</span>
}

to produce this HTML, when Model.Members.Count = 1, just containing the member with id 6653:

<input type="hidden" data-val="true" data-val-required="The Id field is required." id="Members_0__Id" name="Members[0].Id" value="6652" />
<input type="hidden" id="Members_0__FirstName" name="Members[0].FirstName" value="Peter" />
<input type="hidden" id="Members_0__LastName" name="Members[0].LastName" value="Hanson" />
<span>[debug-info: m = 0, id = 6653]</span>
<span>Swanson</span>,
<span>Lisa</span>

How can Members[0].Id have the value of 6652 in the hidden field, and 6653 inside the <span>?

This is the controller method for the view:

public IActionResult ShowSelectedMembers(MembersViewModel vm)
{
    vm.Members = vm.Members.Where(s => s.Selected).OrderBy(o => o.LastName).ThenBy(o => o.FirstName).ToList();
    return View(vm);
}

This is the form which sends the whole member list to the controller method:

// Index.cshtml
@model MyApp.Models.MembersViewModel

<form asp-action="ShowSelectedMembers">
    <button type="submit">View selection</button>
    @if (Model.Members.Any())
    {
        for (int i = 0; i < Model.Members.Count; i++)
        {
            Model.Members[i].Id
            Model.Members[i].FirstName
            Model.Members[i].LastName
            <input type="checkbox" asp-for="@Model.Members[i].Selected" />
            <input type="hidden" asp-for="@Model.Members[i].Id" />
            <input type="hidden" asp-for="@Model.Members[i].FirstName" />
            <input type="hidden" asp-for="@Model.Members[i].LastName" />
        }
    }
</form>

When the form data is sent to the controller method, it contains all the members in that view. The method then filters out all members with Selected set to false.

These are the ViewModels:

public class MembersViewModel
{
    // ... some more properties
    public List<MemberViewModel> Members { get; set; }
}

public class MemberViewModel
{
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public bool Selected { get; set; }
    // ... some more properties
}

UPDATE

To clarify the flow of this operation:

  1. The Index-view contains a form with Selected-checkboxes for each member in the list.

  2. This form passes the whole list of members to the controller method ShowSelectedMembers.

  3. The controller method filters out members with Selected set to false, and passes the selected members to the view ShowSelectedMembers.

  4. The view displays the filtered list, which now has corrupted data.

UPDATE 2

I changed the controller method to this, creating a new instance instead of reusing the viewmodel:

public async Task<IActionResult> ShowSelectedMembers(MembersViewModel vmIn)
{
    List<int> memberIds = new List<int>();
    foreach (MemberViewModel member in vmIn.Members.Where(s => s.Selected).ToList())
    {
        memberIds.Add(member.Id);
    }
    List<Member> selectedMembers = await db.Members
        .Where(s => memberIds.Contains(s.Id))
        .ToListAsync();
    MembersViewModel vmOut = new MembersViewModel {
        Members = auto.Map<List<MemberViewModel>>(selectedMembers)
    };
    return View(vmOut);
}

... but the result is exactly the same.

UPDATE 3

This is so weird!

It appears to be working correctly when I change the razor code to this in ShowSelectedMembers.cshtml:

@*<input type="text" asp-for="@Model.Members[i].Id" />*@
@{
    string _id = $"Members_{i}__Id";
    string _name = $"Members[{i}].Id";
    string _value = Model.Members[i].Id.ToString();
}
<input type="hidden"
       data-val="true"
       data-val-required="The Id field is required." 
       id=@_id
       name=@_name
       value=@_value />

I.e. manually generating the input field rather than using asp-for.


Solution

  • In your first example, you can add ModelState.Clear() and it should give you the result you want.

    public IActionResult ShowSelectedMembers(MembersViewModel vm)
    {
        ModelState.Clear();
        vm.Members = vm.Members.Where(s => s.Selected).OrderBy(o => o.LastName).ThenBy(o => o.FirstName).ToList();
        return View(vm);
    }
    

    However, I'd follow the advice here https://stackoverflow.com/a/1775185/2030565 and adopt the PRG pattern -- POST the form then redirect to a GET action. You will need to store the model in the TempData store if you can't rehydrate the model from a database.