Friday, October 3, 2014

ASP.NET MVC: How to add a model with a dynamic list of child models?

Background

Okay, I admit that the title needs some work.  Let me slide on that.

I like visiting stackoverflow, and I have found it to be a great resource to help me solve the problems that I have encountered.  I believe strongly in doing research first for all my questions, and I usually find answers to them.  However, I have only posted two questions on the site.  Those earned me a tumbleweeds badge.  I ended up removing them, because I felt that they were too specific to a contract job that has come and gone.

But I digress.  I came across a question that interested me.  I like ASP.NET MVC a lot, but I've still got a lot to learn about it, so I didn't feel that I could answer the question outright.  However, I wanted to figure out how to answer it, and I've got multiple ideas.

Posting: Create record using Model List

In a nutshell, the poster wanted to have a view to create a new invoice and add a dynamic number of line items to the invoice all on one page.  I'm not going to address anything else.

Set Up

I pulled up my Sandbox project, added the two models (with some simplification), and added a controller with views.  I let Visual Studio do most of the work, since I wanted to spend time on the problem.

I'm using Visual Studio Premium 2013, and my web project is targeting .NET Framework 4.5.  I'm using Entity Framework and Code-First.

My Attempt

My very first reaction was to open up the Create,cshtml view and modify it, so that it would have a place to add the line items for the invoice, and add the functionality to dynamically add new line items in the client-side.  I thought that a table would be appropriate for the line items:

HTML:
    <table id="invoiceDetails" data-count="1">
        <thead>
            <tr>
                <th>Item Name</th><th>Quantity</th><th>Rate/Hour</th>
            </tr>
        </thead>
        <tbody id="invoiceDetails-Rows">
            <tr>
                <td><input type="text" name="Name" id="Name" /></td>
                <td><input type="number" name="Quantity" id="Quantity" /></td>
                <td><input type="number" name="Price" id="Price" /></td>
            </tr>
        </tbody>
        <tfoot>
            <tr>
                <td colspan="3">
                    <input type="button" id="add-row" value="Add Another Item Row" />
                </td>
            </tr>
        </tfoot>
    </table>

I contemplated automatically adding a new table row when the user clicks on any of the line item text boxes, but decided that I didn't want empty fields to be posted.

JavaScript:
$(document).ready(function () {
    $("#add-row").click(function () {
        var count = $("#invoiceDetails").attr("data-count");
        $("#invoiceDetails-Rows").append(createTableRow(count));
        $("#invoiceDetails").attr("data-count", count++); // increment the count
    });
});

function createTableRow(num) {
    var oTR = document.createElement("tr");
    var oTD = document.createElement("td");
    //<td><input type="text" name="Name" id="Name" /></td>
    var oTB = document.createElement("input");
    oTB.name = "Name"; oTB.id = "Name" + num;
    oTB.type = "text";
    oTD.appendChild(oTB);
    oTR.appendChild(oTD);
    //<td><input type="number" name="Quantity" id="Quantity" /></td>
    oTD = document.createElement("td");
    oTB = document.createElement("input");
    oTB.name = "Quantity"; oTB.id = "Quantity" + num;
    oTB.type = "number";
    oTD.appendChild(oTB);
    oTR.appendChild(oTD);
    //<td><input type="number" name="Price" id="Price" /></td>
    oTD = document.createElement("td");
    oTB = document.createElement("input");
    oTB.name = "Price"; oTB.id = "Price" + num;
    oTB.type = "number";
    oTD.appendChild(oTB);
    oTR.appendChild(oTD);

    return oTR;
}

The JavaScript could be improved, but it will work for now.  I kept the Name attribute of the new input fields the same as the original row, and only changed the Id attribute to append a number, hence keeping the Id attribute unique.  By keeping the Name attribute the same, the page will post back a comma-delimited string.  Example: Name="Labor,Parts,etc."

I tested the Create page, and the client-side worked as I wanted - after a minor correction.

Next, I moved on to the controller.

Initial Controller:
    [HttpPost]
    [ValidateAntiForgeryToken]
    public ActionResult Create([Bind(Include = "InvoiceId,ClientId,Amount,CreationDate,DueDate,InvoiceNotes")] Invoice invoice)
    {
        if (ModelState.IsValid)
        {
            db.Invoices.Add(invoice);
            db.SaveChanges();
            return RedirectToAction("Index");
        }
        return View(invoice);
    }

It's your standard fare, nothing fancy.  I would like for it to use the power of model binding, since that means less code on my part and I'm leveraging the framework.

First attempt: Would a list of InvoiceDetails work?  I changed the method's signature to:
public ActionResult Create(
    [Bind(Include = "InvoiceId,ClientId,Amount,CreationDate,DueDate,InvoiceNotes")] Invoice invoice,
    [Bind(Include = "Name,Quantity,Price")] List<InvoiceDetail> invoiceDetails)
Result: invoiceDetails is null.  Dang it.

Second attempt: Can I get it to work for one line item?
public ActionResult Create(
    [Bind(Include = "InvoiceId,ClientId,Amount,CreationDate,DueDate,InvoiceNotes")] Invoice invoice,
    [Bind(Include = "Name,Quantity,Price")] invoiceDetail)
Result: This works.

Third attempt:  Hey!  What about params?  I can use that to pass in an unknown array of parameters to a method.  Would it work in this case?  Go for it!
public ActionResult Create(
    [Bind(Include = "InvoiceId,ClientId,Amount,CreationDate,DueDate,InvoiceNotes")] Invoice invoice,
    [Bind(Include = "Name,Quantity,Price")] params InvoiceDetail[] invoiceDetails)
Result: No compile errors which is always good.  Same result as the list. The variable is null.

What to do now?  As I mentioned before, if form fields share the same name, the values will be passed to the server as a comma-delimited string.  Let's see what I can do with that.  I commented out my previous changes to the method signature, so that we're back to the initial controller signature.  I've added the code to add the line items and a method to process the line items passed from the web page.

Controller:
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Create(
    [Bind(Include = "InvoiceId,ClientId,Amount,CreationDate,DueDate,InvoiceNotes")] Invoice invoice
)
{
    if (ModelState.IsValid)
    {
        db.Invoices.Add(invoice);
        List<InvoiceDetail> invoiceDetails = new List<InvoiceDetail>(); 
        if (Request["Name"] != null && Request["Quantity"] != null && Request["Price"] != null)
        {
            invoiceDetails = GetInvoiceDetails(Request["Name"], 
                Request["Quantity"], Request["Price"]);
        }
        if (invoiceDetails.Count > 0)
        {
            foreach (var invoiceDetail in invoiceDetails)
            {
                invoiceDetail.InvoiceId = invoice.InvoiceId;
                db.InvoiceDetails.Add(invoiceDetail);
            }
        }
        db.SaveChanges();
        return RedirectToAction("Index");
    }
    return View(invoice);
}

Helper Method:
private List<InvoiceDetail> GetInvoiceDetails(string names, string quantities, string prices)
        {
            List<InvoiceDetail> details = new List<InvoiceDetail>();
            string[] Names = names.Split(',');
            string[] Quantities = quantities.Split(',');
            string[] Prices = prices.Split(',');
            // Not the best, but let's just go with the length on Names (for simplicity)
            // And we need to add validation - but not for the purpose of this post.
            for (int i = 0; i < Names.Length; i++)
            {
                InvoiceDetail detail = new InvoiceDetail() { Name = Names[i] };
                if (i < Quantities.Length) {
                    decimal qty = 0m;
                    decimal.TryParse(Quantities[i], out qty);
                    detail.Quantity = qty; 
                }
                if (i < Prices.Length) {
                    decimal price = 0m;
                    decimal.TryParse(Prices[i], out price);
                    detail.Price = price; 
                }
                details.Add(detail);
            }
            return details;
        }

Result:  This works.  With one page, I can create a new invoice with an unlimited number of line items (relatively unlimited... reasonably unlimited... subject to memory and other limitations...)

But is this the best method?  As it is written, it is not a good solution.  If there is an error with the invoice, all the line items will be lost.  Let's try to fix that.  We need to pass the line items back to the view, if the Invoice model is not valid, then we need to rebuild the line items in the view.

Changes to the Controller:
public ActionResult Create(
            [Bind(Include = "InvoiceId,ClientId,Amount,CreationDate,DueDate,InvoiceNotes")] Invoice invoice 
        )
        {
            List<InvoiceDetail> invoiceDetails = new List<InvoiceDetail>(); 
            if (Request["Name"] != null 
                && Request["Quantity"] != null && Request["Price"] != null)
            {
                invoiceDetails = GetInvoiceDetails(Request["Name"], 
                    Request["Quantity"], Request["Price"]);
            }
            if (ModelState.IsValid)
            {
                db.Invoices.Add(invoice);
                if (invoiceDetails.Count > 0)
                {
                    foreach (var invoiceDetail in invoiceDetails)
                    {
                        invoiceDetail.InvoiceId = invoice.InvoiceId;
                        db.InvoiceDetails.Add(invoiceDetail);
                    }
                }
                db.SaveChanges();
                return RedirectToAction("Index");
            }
            ViewBag.invoiceDetails = invoiceDetails; // pass this back to the view
            return View(invoice);
        }
I moved the declaration and initialization of the invoiceDetails list outside of the if block, and I added the list to the ViewBag.  If the Invoice model is not valid, then I'll send the list of line items back to the view.

Changes to the View:
@{
    List<Sandbox.Models.InvoiceDetail> invoiceDetails = ViewBag.invoiceDetails;
    var count = invoiceDetails != null ? invoiceDetails.Count : 1;
    //if (count <= 0) { count = 1; } // default value
    var name = "";
    var quantity = 0m;
    var price = 0m;
    if (invoiceDetails != null && invoiceDetails.Count > 0)
    {
        name = invoiceDetails[0].Name;
        quantity = invoiceDetails[0].Quantity;
        price = invoiceDetails[0].Price;
    }
}
<table id="invoiceDetails" data-count="@count">
    <thead>
        <tr>
            <th>Item Name</th><th>Quantity</th><th>Rate/Hour</th>
        </tr>
    </thead>
    <tbody id="invoiceDetails-Rows">
        <tr>
            <td><input type="text" name="Name" id="Name" value="@name" /></td>
            <td><input type="number" name="Quantity" id="Quantity" value="@quantity" /></td>
            <td><input type="number" name="Price" id="Price" value="@price" /></td>
        </tr>
        @if (invoiceDetails != null) { 
            for (int i = 1; i < invoiceDetails.Count; i++)
            {
                var num = i + 1;
                var nameid = "Name" + num.ToString();
                var qtyid = "Quantity" + num.ToString();
                var priceid = "Price" + num.ToString();
                name = invoiceDetails[i].Name;
                quantity = invoiceDetails[i].Quantity;
                price = invoiceDetails[i].Price;
        <tr>
            <td><input type="text" name="Name" id="@nameid" value="@name" /></td>
            <td><input type="number" name="Quantity" id="@qtyid" value="@quantity" /></td>
            <td><input type="number" name="Price" id="@priceid" value="@price" /></td>
        </tr>
            }
        }
    </tbody>
    <tfoot>
        <tr>
            <td colspan="3"><input type="button" id="add-row" value="Add Another Item Row" /></td>
        </tr>
    </tfoot>
</table>
It's ugly, but it works.  There's always room for improvement in this code.  I should probably move it into a partial view.  In the controller, perhaps I should have initialized the list to contain one instance of an InvoiceDetail, so I could avoid having to do null checks.  And the list goes on.

Is There a Better Way?

Oh, I am positive that there is.  Here's a few possibilities:

  • Use AJAX or SPA or flavor of the day.
    • Have the initial form show the Invoice model only with a "save" button.  Make a post request to a web service to save the invoice and return the invoice id.  Dynamically, add the fields for the line items with an "update" button that makes a call to a web service to add the line, and then add another set of fields.
  • Separate into two views.
    • Have one view for creating the Invoice.  Upon creating, the user is sent to a second view that displays the invoice and has a form for adding a line item.  After saving the line item, it is added to the invoice display, and the form is cleared for a new line item.  This continues until the user clicks a "Close" button or something similar that makes sense.


No comments:

Post a Comment