This feels hacky, but it works.
Using BeginCollectionItem, you can have the modal add hidden input elements to the DOM.
I wrote an action method that returns JSON with the modelstate (valid/invalid) and errors or partial HTML for invalid and valid submissions, respectively. Based on this, JavaScript either adds the errors to the summary or adds the requisite label and hidden inputs to the initial form.
Then have the main form's viewmodel contain an ICollection
of your data model, called Contacts
in below code, and ASP.NET handles the data binding with no troubles.
Example:
_CollectionItem.cshtml (partial HTML added to main form after valid submission)
@model Project.Models.ContactCreateViewModel
<li>
<div class="collection-item">
@using (Html.BeginCollectionItem("Contacts"))
{
<span class="item-name">@Model.LastName, @Model.FirstName</span> <span class="btn btn-danger delete-item">Delete</span>
@Html.HiddenFor(model => model.FirstName)
@Html.HiddenFor(model => model.LastName)
@Html.HiddenFor(model => model.PhoneNumber)
@Html.HiddenFor(model => model.PhoneExt)
@Html.HiddenFor(model => model.Email)
}
</div>
</li>
_CreateModal.cshtml (partial used for the body of the modal)
@model Project.Models.ContactCreateViewModel
<div class="modal-header">
<button type="button" class="close btn-modal-close" data-dismiss="modal"><i class="fas fa-times"></i></button>
<h4 class="modal-title">New Contact</h4>
</div>
<div class="modal-body">
@using (Html.BeginForm("CreateModal", "Contacts", FormMethod.Post, new { id = "new-contact-form" }))
{
@Html.AntiForgeryToken()
<div class="form-horizontal">
@Html.ValidationSummaryPlaceholder()
@* First Name *@
<div class="form-group">
@Html.LabelFor(model => model.FirstName, htmlAttributes: new { @class = "control-label col-md-4" })
<div class="col-md-8">
@Html.EditorFor(model => model.FirstName, new { htmlAttributes = new { @class = "form-control" } })
@Html.ValidationMessageFor(model => model.FirstName, "", new { @class = "text-danger" })
</div>
</div>
@* Last Name *@
<div class="form-group">
@Html.LabelFor(model => model.LastName, htmlAttributes: new { @class = "control-label col-md-4" })
<div class="col-md-8">
@Html.EditorFor(model => model.LastName, new { htmlAttributes = new { @class = "form-control" } })
@Html.ValidationMessageFor(model => model.LastName, "", new { @class = "text-danger" })
</div>
</div>
@* Phone Number *@
<div class="form-group">
@Html.LabelFor(model => model.PhoneNumber, htmlAttributes: new { @class = "control-label col-md-4" })
<div class="col-md-8">
@Html.EditorFor(model => model.PhoneNumber, new { htmlAttributes = new { @class = "form-control phone" } })
@Html.ValidationMessageFor(model => model.PhoneNumber, "", new { @class = "text-danger" })
</div>
</div>
@* Phone Ext *@
<div class="form-group">
@Html.LabelFor(model => model.PhoneExt, htmlAttributes: new { @class = "control-label col-md-4" })
<div class="col-md-8">
@Html.EditorFor(model => model.PhoneExt, new { htmlAttributes = new { @class = "form-control" } })
@Html.ValidationMessageFor(model => model.PhoneExt, "", new { @class = "text-danger" })
</div>
</div>
@* Email *@
<div class="form-group">
@Html.LabelFor(model => model.Email, htmlAttributes: new { @class = "control-label col-md-4" })
<div class="col-md-8">
@Html.EditorFor(model => model.Email, new { htmlAttributes = new { @class = "form-control" } })
@Html.ValidationMessageFor(model => model.Email, "", new { @class = "text-danger" })
</div>
</div>
@* SUBMIT *@
<div class="form-group">
<div class="col-md-offset-4 col-md-8">
<input type="submit" value="Create" class="btn btn-success" />
</div>
</div>
</div>
}
</div>
@Scripts.Render("~/Scripts/Custom/ajax-add-collection-item.js")
<script>
$(function () {
ajaxAddCollectionItem("new-contact-form", "contacts", function () {
alertify.success("Added new contact");
})
});
</script>
ajax-add-collection-item.js (capture modal form submission, add _CollectionItem.cshtml
to main form)
// Posts form and adds collection item to ul
function ajaxAddCollectionItem(formId, listId, onSuccess = function () { }) {
let $form = $("#" + formId);
$form.submit(function (event) {
event.preventDefault();
$.ajax({
method: "POST",
url: $form.attr("action"),
data: $form.serialize(),
success: function (data) {
let successful = data["success"];
// If form is valid, close modal and append new entry to list
if (successful) {
$("#" + listId).append(data["html"]);
$(".delete-item").click(function (event) {
$(this).closest("li").remove();
});
$(".btn-modal-close").trigger("click");
onSuccess();
}
// If form is not valid, display error messages
else {
displayValidationErrors(data["errors"]);
}
},
error: function (error) {
alert("Dynamic content load failed.");
console.error("Ajax call failed for form: " + $form);
}
});
});
// Populate validation summary
function displayValidationErrors(errors) {
let $ul = $('div.validation-summary-valid.text-danger > ul');
$ul.empty();
$.each(errors, function (i, errorMessage) {
$ul.append('<li>' + errorMessage + '</li>');
});
}
}
ContactsController.cs
public class ContactsController
{
// GET: Contacts/CreateModal
public ActionResult CreateModal()
{
return PartialView("_CreateModal");
}
// POST: Contacts/CreateModal
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> CreateModal(ContactCreateViewModel viewModel)
{
// If form is valid and email does not already exist, send HTML for collection item,
// otherwise send modelstate errors
if (ModelState.IsValid)
{
User user = await UserManager.FindByEmailAsync(viewModel.Email);
if (user == null)
// RenderPartialView returns partial HTML as a string,
// see https://weblog.west-wind.com/posts/2012/May/30/Rendering-ASPNET-MVC-Views-to-String
return Json(new { success = true, html = RenderPartialView("_CollectionItem", viewModel) });
else
ModelState.AddModelError("Email", "Email already exists.");
}
return Json(new { success = false, errors = GetModelStateErrors() });
}
// Actually in base controller class
protected string[] GetModelStateErrors()
{
return ModelState.Values.SelectMany(x => x.Errors).Select(x => x.ErrorMessage).ToArray();
}
}