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