So I've written some code to allow adding and removing elements from a collection dynamically in ASP.NET MVC using AJAX. Adding new items to the collection works as expected, but removing does not. The model collection is updated as expected (the appropriate item is removed by index), but the rendered HTML consistently shows that the last item has been removed (rather than the one at the specified index).
For example, let's say I have the following items:
- Foo
- Bar
- Baz
When I click "remove" next to the item named "Foo", I'd expect the resulting rendered HTML to look as follows:
- Bar
- Baz
When I debug through the controller action, this seems to be the case, as the Names collection on the model only contains those items. However, the rendered HTML that is returned to my AJAX handler is:
- Foo
- Bar
I thought the issue might have to do with caching, but nothing I've tried (OutputCache directive, setting cache:false in $.ajax, etc) is working.
Here is the code:
DemoViewModel.cs
namespace MvcPlayground.Models
{
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
public class DemoViewModel
{
public List<string> Names { get; set; }
public DemoViewModel()
{
Names = new List<string>();
}
}
}
DemoController.cs
The apparent issue here is in the RemoveName method. I can verify that the Model property of the PartialViewResult reflects the collection state as I expect it, but once rendered to the client the HTML is NOT as I expect it.
namespace MvcPlayground.Controllers
{
using MvcPlayground.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
public class DemoController : Controller
{
// GET: Demo
public ActionResult Index()
{
var model = new DemoViewModel();
return View(model);
}
[HttpPost]
public ActionResult AddName(DemoViewModel model)
{
model.Names.Add(string.Empty);
ViewData.TemplateInfo.HtmlFieldPrefix = "Names";
return PartialView("EditorTemplates/Names", model.Names);
}
[HttpPost]
public ActionResult RemoveName(DemoViewModel model, int index)
{
model.Names.RemoveAt(index);
ViewData.TemplateInfo.HtmlFieldPrefix = "Names";
var result = PartialView("EditorTemplates/Names", model.Names);
return result;
}
}
}
Names.cshtml
This is the editor template that I am using to render out the list of Names. Works as expected when adding a new item to the collection.
@model List<string>
@for (int i = 0; i < Model.Count; i++)
{
<p>
@Html.EditorFor(m => m[i]) @Html.ActionLink("remove", "RemoveName", null, new { data_target = "names", data_index = i, @class = "link link-item-remove" })
</p>
}
Index.cshtml
This is the initial page that is loaded, nothing too complicated here.
@model MvcPlayground.Models.DemoViewModel
@{
ViewBag.Title = "Index";
}
<h2>Index</h2>
@using (Html.BeginForm())
{
@Html.AntiForgeryToken()
<div class="form-horizontal">
<h4>Demo</h4>
<hr />
@Html.ValidationSummary(true, "", new { @class = "text-danger" })
<div class="container-collection" id="names">
@Html.EditorFor(m => m.Names, "Names")
</div>
@Html.ActionLink("Add New", "AddName", "Demo", null, new { data_target = "names", @class = "btn btn-addnew" })
<div class="form-group">
<div class="col-md-offset-2 col-md-10">
<input type="submit" value="Save" class="btn btn-default" />
</div>
</div>
</div>
}
Index.js
This script handles the calls to AddName and RemoveName. Everything here works as I'd expect.
$('form').on('click', '.btn-addnew', function (e) {
e.preventDefault();
var form = $(this).closest('form');
var targetId = $(this).data('target');
var target = form.find('#' + targetId);
var href = $(this).attr('href');
$.ajax({
url: href,
cache: false,
type: 'POST',
data: form.serialize()
}).done(function (html) {
target.html(html);
});
});
$('form').on('click', '.link-item-remove', function (e) {
e.preventDefault();
var form = $(this).closest('form');
var targetId = $(this).data('target');
var target = form.find('#' + targetId);
var href = $(this).attr('href');
var formData = form.serialize() + '&index=' + $(this).data('index');
$.ajax({
url: href,
cache: false,
type: 'POST',
data: formData
}).done(function (html) {
target.html(html);
});
});
The reason for this is because you posting back you model, and the values of your model are added to
ModelState
by theDefaultModeBinder
. TheHtmlHelper
methods that generate form controls (in your case@Html.EditorFor(m => m.Names, "Names")
) use the values fromModelState
if they exist (rather that the actual property values). The reason for this behavior is explained in the second part of this answer).In your case the
ModelState
values areso even though the updated model your returning contains only
Name[0]: Bar
andName[1]: Baz
, TheEditorFor()
method, in the first iteration will check for aModelState
value ofName[0]
, discover that it exists and outputFoo
.You could solve this by using
ModelState.Clear()
before returning the view (although the correct approach is to use the PRG pattern), but in your case none of this seems necessary, especially having to post back the whole model. You could simply post back the index of the item, or theName
value, (or if it was a complex object, then an ID value), remove the item and return aJsonResult
indicating success or otherwise. Then in the ajaxsuccess
callback, remove the item from the DOM.