Tuesday, July 02, 2013

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 : &#39;Items_&#39; + $index() + &#39;__ProductName&#39;, name: &#39;Items[&#39; + $index() + &#39;].ProductName&#39; }" type="text" value="" />
          </td>
          <td>
            <input data-bind="value: Quantity, attr: { id : &#39;Items_&#39; + $index() + &#39;__Quantity&#39;, name: &#39;Items[&#39; + $index() + &#39;].Quantity&#39; }" type="text" value="" />
          </td>
          <td>
            <input data-bind="value: Price, attr: { id : &#39;Items_&#39; + $index() + &#39;__Price&#39;, name: &#39;Items[&#39; + $index() + &#39;].Price&#39; }" 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.

4 comments:

Narcis C said...

Hello, gr8 post, you make a nice work!
But one question:if Company (in Master) and Product (in Detail) are drop-down lists, your model still work ?

Thank you.

Blog4Learner said...

Hi Marcel,

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

Anonymous said...

Hi Marcel, i'm trying to make your example work in my code but whenever y press the add button it goes to the submit function, and if I put type="button" it does not do anything. Could you please help me? It´s driving me crazy. Thank you very much! My mail is mili_biss@hotmail.com

subhabrata said...

Excellent

Can you give some light so that product in detail section comes from a dropdown combobox (from product master)


regards