Posting dynamic Master / Detail forms with Knockout
I have the following dynamic form in my GitHub Inventory project:
I created the form with KnockoutJS, using some ideas from this article. The detail view looks like this:
<table id="acquisition_items" class="jtable"> <thead> <tr> <th>Product Name</th> <th>Quantity</th> <th>Price</th> <th>Value</th> <th><button data-bind="click: addItem">[+]</button></th> </tr> </thead> <tbody data-bind="foreach: items"> <tr> <td> <input data-bind="value: ProductName" type="text" value="" /> </td> <td> <input data-bind="value: Quantity" type="text" value="" /> </td> <td> <input data-bind="value: Price" type="text" value="" /> </td> <td> <input data-bind="value: Value" readonly="readonly" type="text" value="" /> </td> <td> <button data-bind="click: $parent.delItem">[--]</button> </td> </tr> </tbody> </table>
and the corresponding ViewModel:
function ViewModel() { var self = this; self.CompanyName = ko.observable(); self.Date = ko.observable(); self.items = ko.observableArray([new Item()]); self.addItem = function() { self.items.push(new Item()); }; self.delItem = function(item) { self.items.remove(item); if (self.items().length == 0) self.addItem(); }; } function Item() { var self = this; self.ProductName = ko.observable(); self.Quantity = ko.observable(); self.Price = ko.observable(); self.Value = ko.computed(function() { try { var result = parseFloat(self.Quantity()) * parseFloat(self.Price()); return isNaN(result) ? 0 : result; } catch(ex) { return 0; } }); }
(Note that I will re-create an empty record if the last one gets deleted.)
Now, as Pete says in the article, the usual way of POST-ing a knockout viewmodel to the server is to serialize it and post that - however, like him, I would prefer to use a regular POST / refresh. (I like getting the feedback of the browser refreshing the page.) There is the problem of having the correct field IDs/names (in the HTML) so that the MVC model binder maps them correctly to the model. Pete solved that with a knockout plugin but, as he says, it has some disadvantages:
- you need to have an empty item in the items list; that would be ok, but
- the root of the repeating groups has to be a fieldset
Since I wanted my repeating records in a table and I wanted a more generic solution, I came up with the DataBinding
extension method (and a helper to get the property name from an Expression<Func<U, V>>
):
public static string DataBinding<TModel, U, V>(this HtmlHelper<TModel> helper, Expression<Func<TModel, IEnumerable<U>>> listExpr, Expression<Func<U, V>> memberExpr) { var meta1 = ModelMetadata.FromLambdaExpression(listExpr, helper.ViewData); var listName = meta1.PropertyName; var itemName = GetProperty(memberExpr).Name; return string.Format("value: {1}, attr: {{ id : '{0}_' + $index() + '__{1}', name: '{0}[' + $index() + '].{1}' }}", listName, itemName); } public static PropertyInfo GetProperty<T, U>(this Expression<Func<T, U>> expression) { MemberExpression memberExpression = null; switch (expression.Body.NodeType) { case ExpressionType.Convert: memberExpression = ((UnaryExpression) expression.Body).Operand as MemberExpression; break; case ExpressionType.MemberAccess: memberExpression = expression.Body as MemberExpression; break; } if (memberExpression == null) throw new ArgumentException("Not a member access", "expression"); return memberExpression.Member as PropertyInfo; }
This is how it's used to generate the HTML:
<table id="acquisition_items"> <thead> <tr> <th>Product Name</th> <th>Quantity</th> <th>Price</th> <th>Value</th> <th><button data-bind="click: addItem">[+]</button></th> </tr> </thead> <tbody data-bind="foreach: items"> <tr> <td> <input data-bind="@Html.DataBinding(m => m.Items, it => it.ProductName)" type="text" value="" /> </td> <td> <input data-bind="@Html.DataBinding(m => m.Items, it => it.Quantity)" type="text" value="" /> </td> <td> <input data-bind="@Html.DataBinding(m => m.Items, it => it.Price)" type="text" value="" /> </td> <td> <input data-bind="value: Value" readonly="readonly" type="text" value="" /> </td> <td> <button data-bind="click: $parent.delItem">[--]</button> </td> </tr> </tbody> </table>
and it generates the following HTML:
<table id="acquisition_items" class="jtable"> <thead> <tr> <th>Product Name</th> <th>Quantity</th> <th>Price</th> <th>Value</th> <th><button data-bind="click: addItem">[+]</button></th> </tr> </thead> <tbody data-bind="foreach: items"> <tr> <td> <input data-bind="value: ProductName, attr: { id : 'Items_' + $index() + '__ProductName', name: 'Items[' + $index() + '].ProductName' }" type="text" value="" /> </td> <td> <input data-bind="value: Quantity, attr: { id : 'Items_' + $index() + '__Quantity', name: 'Items[' + $index() + '].Quantity' }" type="text" value="" /> </td> <td> <input data-bind="value: Price, attr: { id : 'Items_' + $index() + '__Price', name: 'Items[' + $index() + '].Price' }" type="text" value="" /> </td> <td> <input data-bind="value: Value" readonly="readonly" type="text" value="" /> </td> <td> <button data-bind="click: $parent.delItem">[--]</button> </td> </tr> </tbody> </table>
This solves the id/name problem by using the $index
knockout observable; newly created records will always have the correct values for these attributes, no matter how rows are added or deleted.
Again, thanks to Pete for the inspiration and I hope this will help someone else.
Comments
But one question:if Company (in Master) and Product (in Detail) are drop-down lists, your model still work ?
Thank you.
Great post..
I have modified your code and run it doesn't execute correctly.
I the Acquisition view, when i add a product, the new row for product entry was not fired.
Please help me to solve the issue ASAP.
And alse when i add a new row i need to select a product from list with search functionality.
Thanks in advance.
Vinoth | vinom1389@yahoo.com
Can you give some light so that product in detail section comes from a dropdown combobox (from product master)
regards