Pages

Sunday, December 20, 2015

Knockout - example how to bind checkbox to radiobutton

I just want to share one small example of how to bind 2 collections together - using subscribe function. It will be done as a simple Asp.Net MVC application with Knockout + JQuery

That application solves a problem of selecting duplicates. Imagine that you have a database of customers, each customer has email address, name and id. It can later be linked to other entities like orders, but thats out of the scope of the current app. In order to manage that DB sometime you have to search for duplicates, e.g. "John Smith" with email "js@mail.com" should be the same person as "Johnny Smith" with the same email and it would be wise to let your users check that information.

So, somehow you managed to get all duplicates and want to show a form to the user to submit the merging process. I will be using that simple controller:

    public class DuplicatesController : Controller
    {
        public ActionResult Index()
        {
            var model = new DuplicatesCollectionViewModel();

            model.Duplicates = new List<DuplicateViewModel>
            {
                new DuplicateViewModel {ContactName = "Duplicate 1", ContactId = Guid.NewGuid(), Email = "mail1@mail.com", Reason = ""},
                new DuplicateViewModel {ContactName = "Duplicate 2", ContactId = Guid.NewGuid(), Email = "mail1@mail.com", Reason = "same email"}
            };

            return View(model);
        }

        public JsonResult GetAnotherDuplicate()
        {
            var number = new Random().Next(100);

            var result = new JsonResult
            {
                Data = new DuplicateViewModel
                    {
                        ContactName = string.Format("Duplicate {0}", number),
                        ContactId = Guid.NewGuid(),
                        Email = string.Format("mail{0}@mail.com", number),
                        Reason = "another one"
                    }
            };

            return result;
        }

        public void MergeContacts(List<Guid> duplicates, Guid originalId)
        {
            //do stuff
        }
    }

And model for the backend:
    public class DuplicatesCollectionViewModel
    {
        public List<DuplicateViewModel> Duplicates { get; set; }
    }

    public class DuplicateViewModel
    {
        public Guid ContactId { get; set; }

        public string ContactName { get; set; }

        public string Email { get; set; }

        public string Reason { get; set; }
    }
For the frontend I will use jquery and knockout.
There will be 2 models: DuplicateContact and DuplicatesViewModel. First one to store the information about every customer and the later one is the main model for the page. The view model has 2 observable arrays to store the all possible duplicates (from the server) and duplicates selected by user for the merge. Selected duplicates are bound to checkboxes via the "checked" binding.

Another property of the view model is the "primaryItem". It is bound to radiobuttons and also there is a subscription - to keep checkboxes and radiobuttons in sync. User can`t select primary item if it is not among merged items, so if this happens I want to include that item into the merged collection. Also that will flag item that it cant be excluded from that collection.

Well, here is the code for the page, I hope it is self-explanatory:
@model KnockoutRadiobutton.Models.DuplicatesCollectionViewModel

<div id="duplicatesForm">
    <table id="duplicatesTable">
        <thead>
        <tr>
            <th></th>
            <th></th>
            <th>Merge</th>
            <th>Primary</th>
        </tr>
        </thead>
        <tbody data-bind="foreach: duplicates">
        <tr>
            <td>
                <span style="font-weight: bold" data-bind="text: contactName"></span>
            </td>
            <td>
                <span data-bind="text: reason"></span>
            </td>
            <td>
                <input type="checkbox" data-bind="value: contactId, checked: $parent.mergeItems, attr: {disabled: isDisabled}" />
            </td>
            <td>
                <input type="radio" name="PrimaryContactRadioGroup" data-bind="value: contactId, checked: $parent.primaryItem" />
            </td>
        </tr>
        <tr>
            <td></td>
            <td style="padding-top: 0; padding-bottom: 0;">
                <span data-bind="text: email"></span>
            </td>
            <td></td>
            <td></td>
        </tr>
        </tbody>
    </table>
</div>

<div>
    <input type="button" data-bind="click: addNewDuplicate" value="Add another one" />
    <input type="button" data-bind="click: submitDuplicates" value="Send a merge request" />
</div>

<script src="~/Scripts/jquery-1.10.2.min.js"></script>
<script src="~/Scripts/knockout-3.4.0.js"></script>

<script>
    function DuplicateContact(contactId, contactName, email, reason) {
        var self = this;

        self.contactId = contactId;
        self.contactName = contactName;
        self.email = email;
        self.reason = reason;

        self.isDisabled = ko.observable(false);
    }

 function DuplicatesViewModel() {
     var self = this;

  self.duplicates = ko.observableArray();
  self.mergeItems = ko.observableArray();

  self.primaryItem = ko.observable();
     self.primaryItem.subscribe(function (item) {
         //mark primary item as selected for merge
         var isForMerge = ko.utils.arrayFirst(self.mergeItems(), function(mergeItem) {
          return mergeItem === item;
      });
   if (!isForMerge) self.mergeItems.push(item);

   //mark new primary item
   ko.utils.arrayForEach(self.duplicates(), function(duplicate) {
       duplicate.isDisabled(false);
       if (duplicate.contactId === item) {
           duplicate.isDisabled(true);
       }
   });
     });

     self.getPrimaryContact = function() {
         var primaryContactId = self.primaryItem();

         var filter =  ko.utils.arrayFilter(self.duplicates(), function(duplicate) {
             return duplicate.contactId === primaryContactId;
         });


   if (filter.length === 1) return filter[0].contactId;

         return null;
  };

  self.addNewDuplicate = function() {
      $.ajax({
          type: "POST",
                url: "@Url.Action("GetAnotherDuplicate")",
                success: function(data) {
     var matchDuplicate = ko.utils.arrayFirst(self.duplicates(), function(item) {
         return data.ContactId === item.ContactId;
     });
                    if (matchDuplicate) return;

                    self.duplicates.push(new DuplicateContact(
                            data.ContactId,
                            data.ContactName,
                            data.Email,
                            data.Reason));
                }
            });
  };

     self.submitDuplicates = function () {
         var duplicates = self.mergeItems();
         var primary = self.getPrimaryContact();

         if (duplicates.length == 0) {
             alert("You have to select duplicates!");
             return;
         }
         if (!primary) {
             alert("You have to select the primary contact!");
             return;
         }

         $.ajax(
                {
                    type: "POST",
                    url: "@Url.Action("MergeContacts")",
             data: {
                 duplicates: duplicates,
                 originalId: primary,
             },
             dataType: "json",
             traditional: true
         });
     };
 }

 $(function () {
        var viewModel = new DuplicatesViewModel();

        @foreach (var duplicate in Model.Duplicates)
  {
   <text>
    viewModel.duplicates.push(new DuplicateContact(
     '@duplicate.ContactId',     
     '@duplicate.ContactName',
     '@duplicate.Email',
     '@duplicate.Reason'));
     </text>
  }

     ko.applyBindings(viewModel);
    });

// allows debugging of dynamically loaded scripts
//# sourceURL=Duplicates.js
</script>


No comments:

Post a Comment