0

I'm using Entity Framework 7 with ASP.NET MVC 5.

I have some forms that look like this. Clicking on one of the "new" buttons brings up a Bootstrap modal that looks like this. Submitting the modal form adds a new entity to the database before appending its name and primary key to the selectlist.

This works, but if the user changes their mind, the item(s) created via the modal (location in this case) stick around forever. So ideally none of the child items would be created until the main form is finished. While the example only has two simple fields, other data models have more than half a dozen, which may include complex fields of their own (but preventing that wouldn't be a horrible restriction).

So what's the best way to do this, nested divs serialized by JavaScript? Nested divs would also make it easy to allow reordering by the user, which is the end goal.

Does ASP.NET have a better way to handle this?

Sinjai
  • 1,085
  • 1
  • 15
  • 34
  • This seems like the kind of question that **has** to have been asked before. I'm just not sure how to word it to find results. – Sinjai Dec 06 '17 at 07:05
  • Refer [this answer](http://stackoverflow.com/questions/28019793/submit-same-partial-view-called-multiple-times-data-to-controller/28081308#28081308) for some options (and [this one](http://stackoverflow.com/questions/40539321/partial-view-passing-a-collection-using-the-html-begincollectionitem-helper/40541892#40541892) for a more detailed example using `BeginCollectionItem`). But of course you can always add the newly added item into your view with an associated 'Delete' button –  Dec 06 '17 at 07:08
  • @Stephen Thanks for the resources. As for the delete button, that definitely needs to exist regardless, but as it stands now it would add and remove from the database every time someone changes their mind or navigates away from the page -- which I'd have to be sure to detect to remove all the orphaned child items. Unless you mean add all the data from the newly added child item to the original form. In that case, that's what this question is for. – Sinjai Dec 06 '17 at 07:21
  • That's what the links I gave you explain how to do - dynamically add and remove collection items in the view and then save it all in one submit action –  Dec 06 '17 at 07:23
  • @StephenMuecke You made them sound like two different things -- "you can always" has an implied [instead] -- my bad. Will read the linked answers after sleep. – Sinjai Dec 06 '17 at 07:27
  • @StephenMuecke, I'm not sure I can use those for my purposes. I want the actual form to be in a modal, then just the name to show up when the user clicks "submit". Perhaps I could use JS to add a bunch of hidden DOM elements and still use `BeginCollectionItem`, but that feels hacky to me, which is obviously a red flag. – Sinjai Dec 10 '17 at 19:52
  • @StephenMuecke could you look over [my answer](https://stackoverflow.com/a/48030514/) and tell me if I'm reinventing the wheel? It works but feels pretty hacky to me. – Sinjai Dec 30 '17 at 02:52

1 Answers1

0

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();
    }
}
Sinjai
  • 1,085
  • 1
  • 15
  • 34