0

I have an application that shows a checklist with a dynamic list of tasks. For now all the tasks have 2 radio buttons: one for Done and one for Not Done. I will be adding new task types (textboxes etc.) later.

The main view calls an editor template that creates each task. The checklist model keeps the tasks in a property "Tasks" of type List<Task>. This is fine for display, but has trouble on form submit.

I want to have each task save independently, so I have placed a <form> element around the controls in the editor template.

Controller

public class CheckController : Controller
{
    [HttpGet]
    public ActionResult Index()
    {
        var checklist = new Checklist();
        GetData(checklist);
        return View(checklist);
    }

    [HttpPost]
    public ActionResult Index(Task model)
    {
        var task = (Task)model;
        return Content($"Save Task:{task.TaskId} Value:{task.IsDone}");
    }
}

Models

public class Checklist
{
    public List<Task> Tasks { get; set; }
    public Checklist()
    {
        Tasks = new List<Task>();
    }
}

public class Task
{
    public int TaskId { get; set; }
    public string TaskName { get; set; }
    public bool IsDone { get; set; }
}

Views

@model Checkpoint.Models.Checklist
<table>
    @Html.EditorFor(x => x.Tasks);
</table>


@model Checkpoint.Models.Task
<tr>
    <td>@Model.TaskName</td>
    <td data-ftid="@Model.TaskId">
        @{
            using (Html.BeginForm("Index", "Check", FormMethod.Post))
            {
                @Html.HiddenFor(x => x.TaskId)
                @Html.RadioButtonFor(x => x.IsDone, false)<span>Incomplete</span>
                @Html.RadioButtonFor(x => x.IsDone, true)<span>Complete</span>
                <button role="button" type="submit" name="taskAction">Submit</button>
            }
        }
    </td>
</tr>

Rendered HTML

<tr>
    <td>First thing</td>
    <td>
        <form action="/Checklist" method="post">
            <input id="Tasks_0__TaskId" name="Tasks[0].TaskId" type="hidden" value="1" />
            <input checked="checked"id="Tasks_0__IsDone" name="Tasks[0].IsDone" type="radio" value="False" />
            <span>Incomplete</span>
            <input id="Tasks_0__IsDone" name="Tasks[0].IsDone" type="radio" value="True" 
            <span>Complete</span>
            <button role="button" type="submit" name="taskAction">Submit</button>
        </form>
    </td>
</tr>
<tr>
    <td>Second thing</td>
    <td>
        <form action="/Checklist" method="post">
            <input id="Tasks_1__TaskId" name="Tasks[1].TaskId" type="hidden" value="2" />
            <input checked="checked" id="Tasks_1__IsDone" name="Tasks[1].IsDone" type="radio" value="False" />
            <span>Incomplete</span>
            <input id="Tasks_1__IsDone" name="Tasks[1].IsDone" type="radio" value="True" />
            <span>Complete</span>
            <button role="button" type="submit" name="taskAction">Submit</button>
        </form>
    </td>
</tr>

This does submit the request, but the data looks like this:

Tasks[1].TaskId: 1
Tasks[1].IsDone: True

When it reaches the controller action (which accepts type Task), the property values are null.

How can I get my task data correctly in the controller action? Or am I going about this in the totally wrong way?

Bonus: what would be the best approach for adding the new task types?

Bloozy
  • 3
  • 2
  • 1
    First, I'd consider renaming your `Task` class or I'd use is fully qualified name so you don't accidentally confuse `MyAppNamespace.Task` with `System.Threading.Tasks.Task`. – Jasen Apr 04 '19 at 23:35
  • 1
    Is the index 0 missing in your form submission? Read this https://stackoverflow.com/questions/19964553/mvc-form-not-able-to-post-list-of-objects to see how to post a collection. – Jasen Apr 04 '19 at 23:45
  • 1
    In addition to Jasen's comment, replace the IEnumerable in your action with Checkpoint.Models.Task. If you are posting one task you do not need an Enumerable object. – Rahatur Apr 05 '19 at 09:01
  • `@{ using` can simply be `@using` – Mark Schultheiss Apr 06 '19 at 19:24
  • Also be aware `@Model.TaskName` is outside the form and will be null in your posted model perhaps. – Mark Schultheiss Apr 06 '19 at 19:34
  • @Jasen I checked the link you provided, but that poster has a single form to submit a whole collection while I just want to submit one element of my list. I assume I have to use multiple forms for this? I'm trying to eliminate the need for a save button by recording user actions as they happen. – Bloozy Apr 06 '19 at 19:38
  • @MarkSchultheiss The task name is not editable and doesn't need to be saved. I just want to send the task ID and value (done vs not) back to the database. – Bloozy Apr 06 '19 at 19:40
  • @Rahatur I made the change you suggested, but the action is getting 0 for ID and false for isDone when I'm submitting ID:2 and isDone:true. – Bloozy Apr 06 '19 at 19:41
  • The inputs' `name` attribute value is critical in binding the posted values to the action parameters. Here you are using indexes appropriate for passing collections but your action accepts a single item -- therefore your values do not bind. – Jasen Apr 06 '19 at 20:08
  • If you do NOT want the name, it would be best to create a `TaskPostback` or some such without it so that you can post that ID/Boolean value object/list of objects back. – Mark Schultheiss Apr 07 '19 at 11:47
  • NOTE: I typically see this "I want to post individually" done as an ajax post. – Mark Schultheiss Apr 07 '19 at 12:06
  • @Bloozy Data is not getting populated in action because the name of the hidden field is not patching the name of the Action's parameters. e.g. Tasks[0].TaskId will not get populated in TaskId field. Try to find out why the name is generated in such way? Perhaps you are binding from a list in the cshtml? – Rahatur Apr 08 '19 at 15:47

2 Answers2

1

Since you're not trying to post the collection all in one submission you'll need to lose the index on the model name.

Tasks[0].TaskId

Adjust your views. The variable name "task" needs to match the action parameter because the HtmlHelpers will generate name attributes based off this string.

@model Checkpoint.Models.Checklist
<table>
    @foreach(Task task in Model.Tasks)
    {
        @Html.EditorFor(m => task)
    }
</table>

The rendered html looks something like

<input id="task_TaskId" name="task.TaskId" type="hidden" value="1">
...

The action needs a "task" parameter to match to bind its values

[HttpPost]
public ActionResult Index(Task task)
{
    return Content($"Save Task:{task.TaskId} Value:{task.IsDone}");
}

If we use a partial view instead of an editor template we can somewhat relax the name dependency on "task".

@model Checkpoint.Models.Checklist
<table>
    @foreach(Task t in Model.Tasks)
    {
        @Html.PartialView("_TaskForm", t)
    }
</table>

And your editor template content will work just the same.

_TaskForm

@model Checkpoint.Models.Task
<tr>
    <td>@Model.TaskName</td>
    <td data-ftid="@Model.TaskId">
        @{
            using (Html.BeginForm("Index", "Check", FormMethod.Post))
            {
                @Html.HiddenFor(x => x.TaskId)
                @Html.RadioButtonFor(x => x.IsDone, false)<span>Incomplete</span>
                @Html.RadioButtonFor(x => x.IsDone, true)<span>Complete</span>
                <button role="button" type="submit" name="taskAction">Submit</button>
            }
        }
    </td>
</tr>

And the rendered html

<input id="TaskId" name="TaskId" type="hidden" value="1">
...

The gotcha with all of the above is the html id attributes will not be unique. So if that's critical you'll need to manually iterate the collection to build each form manually so you can exactly specify the id values. That is, you'd lose the small modular editor templates.

Letting the framework auto-generated "unique" ids... we override the name attribute (mind the capitalization for "Name") we have:

@model Checkpoint.Models.Checklist
<table>
    @for(int i = 0; i < Model.Tasks.Count; i++)
    {
        <tr>
           <td>@Model.Tasks[i].TaskName</td>
           <td data-ftid="@Model.Tasks[i].TaskId">
               @using (Html.BeginForm("Index", "Check", FormMethod.Post))
               {
                   @Html.HiddenFor(x => Model.Tasks[i].TaskId, new { Name="TaskId" })
                   @Html.RadioButtonFor(x => Model.Tasks[i].IsDone, false, new { Name="IsDone" })<span>Incomplete</span>
                   @Html.RadioButtonFor(x => Model.Tasks[i].IsDone, true, new { Name="IsDone" })<span>Complete</span>
                   <button role="button" type="submit" name="taskAction">Submit</button>
              }
           </td>
       </tr>
    }
</table>
Jasen
  • 13,170
  • 3
  • 43
  • 64
0

I was able to make this work with the Editor Template by dropping the helpers and creating the controls manually.

@model Checkpoint.Models.Task
<tr>
    <td>@Model.TaskName</td>
    <td data-ftid="@Model.TaskId">
        @using (Html.BeginForm("Index", "Check", FormMethod.Post))
        {
            <input name="TaskId" type="hidden" value="@Model.TaskId" />
            <input name="IsDone" type="radio" value="True" @Html.Raw(Model.IsDone ? "checked=checked" : null) />
            <span>Complete</span>
            <input name="IsDone" type="radio" value="False" @Html.Raw(!Model.IsDone ? "checked=checked" : null) />
            <span>Incomplete</span>
            <button role="button" type="submit" name="taskAction" value="@Model.TaskId">Submit</button>
        }
    </td>
</tr>

I wasn't overly happy with this and opted instead for @Jasen's partial view solution. I was able to obtain unique element IDs by overriding the 'id' attribute in the helper. I used the TaskId since it's unique, but could otherwise have passed in a loop index from the main view.

@model Checkpoint.Models.Task
<tr>
    <td>@Model.TaskName</td>
    <td data-ftid="@Model.TaskId">
        @using (Html.BeginForm("Index", "Check", FormMethod.Post))
        {
            @Html.HiddenFor(x => x.TaskId, new { id = "Task_" + Model.TaskId + "_hidIsDone" })
            @Html.RadioButtonFor(x => x.IsDone, false, new { id = "Task_"+ Model.TaskId + "_radIsDoneF" })<span>Incomplete</span>
            @Html.RadioButtonFor(x => x.IsDone, true, new { id = "Task_" + Model.TaskId + "_radIsDoneT" })<span>Complete</span>
            <button role="button" type="submit" name="taskAction">Submit</button>
        }
    </td>
</tr>
Bloozy
  • 3
  • 2