Complex Master-Detail Form using Knockout.js and ASP.NET MVC

Recently I was tasked with implementing the following scenario. In short, Customer has a list of contacts

I already have developed the customer Add/Update screen with around 20 or something customer related input fields. New task was to plug a table inside this screen to display existing contacts and also with the ability to add new ones.

Just to give you an idea on how the UI looks like. Refer the following image.

Customer Contacts UI
Seems simple enough. And when I click on "Add Customer" and that small pencil icon, following UIs pops up.



Add New Contact UI

Update Contact UI



I have developed the system using ASP.NET MVC4 and Knockout. I implemented an API to use for AJAX calls and someday, for mobile devices to communicate with the system.

There are simple rules to this scenario.

  • If I'm adding a new customer, then I should be able to add new contacts to this customer also. Since the new customer is not yet in the database, we can't add a new contact until we add a customer. So, we have to store the contacts list and save everything in the database at once along with the customer.
  • If I'm updating a customer, and then If I choose to update a contact, then its safe to store in the database when we click the UPDATE button. 
  • If I'm updating a customer and If I add a new contact, then until I click the SAVE button for customer, contacts should not get saved in the database.
So how would you implement this?

I'm not going to post the entire code here. Just the concepts around it.

1. Models (knockout)

Customer Contact Data Model
var CustomerContactModel = function (cid, uuid) {

        var self = this;

        self.TempID = ko.observable(uuid);

        self.CustomerID = ko.observable(cid);

        self.ID = ko.observable(0);

        self.FirstName = ko.observable("");

        //---- rest of the properties -----//
    };
Notice the TempID property. I'm using this to uniquely identify each contact. Because I can't use ID since it's been used from backend to add/update a record in the database. So whenever I'm trying to update an item in the modal popup, I can look for this TempID and retrieve the corresponding record and update it. When I click update, (as shown below) the item in the observable array gets updated.

Customer Data Model
var CustomerModel = function (id) {

        var self = this;

        self.ID = ko.observable(id);

        self.CustomerNumber = ko.observable();

        self.Name = ko.observable("");

        // Contacts List
        self.CustomerContacts = ko.observableArray([]);

       //---- rest of the properties -----//
 }; 
Notice the CustomerContacts Observable array. I'm storing each new contact in this array when the ADD button is clicked.

2. View Models (knockout)

Create/Update Customer View Model
 var CreateUpdateCustomerViewModel = function (updatingId, returnUrl, csrfToken) {

        var self = this;

        // Set a flag to see if we are adding a new or updating an existing one
        self.isUpdating = (updatingId > 0);
        self.UpdatingCustomerID = updatingId;

        self.lookupModel = new LookupsViewModel();

        self.dataModel = ko.observable();

        self.customerStatuses = ko.observableArray([
            { ID: 1, Type: "Active" },
            { ID: 2, Type: "Pending" },
            { ID: 3, Type: "Suspended" },
            { ID: 4, Type: "Cancelled" }
        ]); 

         self.userSalutationsList = ko.observableArray([]);


        self.initialize = function () { 
            self.lookupModel.getUserSalutations(self.userSalutationsList);

            self.dataModel(new CustomerModel());

            // Are we updating an existing user?
            if (self.isUpdating) {
                // Retrieve the updating user data from the api
                self.getCustomerData(updatingId);
            }
        };

        self.loadSuccess = function (data) {

            var model = self.dataModel();

            // Set the initial data
            model.ID(data.ID);
            model.CustomerNumber(data.CustomerNumber);
            // Customer Identification Details
            $("#ViewTitle").text("Update Customer - " + data.Name);
            model.Name(data.Name);

            // ---- Rest of the properties initialization ---//

            self.getCustomerContactsData(self.UpdatingCustomerID, 1, 10);
        };


        self.currentPage = ko.observable(1);
        self.pageCount = ko.observable(1);
        self.pageSize = ko.observable(10);
        self.totalCount = ko.observable(1);

        self.contactsLoadSuccess = function (data) {
            self.currentPage(data.CurrentPage);
            self.pageCount(data.PageCount);
            self.pageSize(data.pageSize);
            self.totalCount(data.TotalCount);

            var mappedData = ko.utils.arrayMap(data.Items, function (item) {
                var c = new CustomerContactModel(item.CustomerID);
                c.ID(item.ID); 
                c.TempID(createUUID()); 
                c.FirstName(item.FirstName);
                // ---- Rest of the properties initialization ---//
                return c;
            });

            self.dataModel().CustomerContacts(mappedData);

            if (data.PageCount > 1) {
                self.createPagination();

                $("#ContactsPagination").show();
            }
            else
                $("#ContactsPagination").hide();
        };


        self.createPagination = function () {

            $("#paginationHolder").pagination({
                pages: self.pageCount(),
                itemsOnPage: self.pageSize(),
                currentPage: self.currentPage(),
                cssStyle: 'pagination',
                onPageClick: self.loadPage,
                nextText: '<i class="fa fa-angle-double-right"></i>',
                prevText: '<i class="fa fa-angle-double-left"></i>'
            });

        };

        self.reloadContactPage = function () {
            self.getCustomerContactsData(self.currentPage(), self.pageSize() || 10);
        };

        self.getCustomerData = function (id) {
            self.VMH.attachSpinner();
            self.VMH.apiGet('api/customers/' + id, null, self.loadSuccess);
        };

        self.getCustomerContactsData = function (customerid, pageNo, pageSize) {
            var url = 'api/customers/' + customerid + "/contacts/";
            url += pageNo + "/" + pageSize;
            self.VMH.apiGet(url, null, self.contactsLoadSuccess);
        };


        self.loadPage = function (pageNo, event) {
            self.getCustomerContactsData(self.UpdatingCustomerID, pageNo, self.pageSize() || 10);
        };


        self.save = function (model) {

                var unmappedModel = ko.mapping.toJSON(model);
 
                if (self.isUpdating) {
                    self.updateCustomer(unmappedModel, csrfToken);
                }
                else {
                    self.VMH.apiPost('api/customers/create',
                       unmappedModel,
                       csrfToken,
                       self.successfulCreate
                   );
                } 
        };

        self.updateCustomer = function (model, token) {
       
            self.VMH.apiPost('api/customers/update',
                  model,
                  token,
                  self.successfulUpdate
              );
        };

        self.initialize();
    };
Note that I have a quite a lot of functions here. self.getCustomerData() ,  self.getCustomerContactsData() does what it's name implies. Getting the data from API. First I fire up the self.initialize() method. It will retrieve the customer details for a given customer ID. No server calls will occur if its adding a new customer. After the customer details have been retrieved, I fire up a call to get a paged list of customer contacts.

Create/Update Customer Contacts View Model
var CreateUpdateCustomerContactViewModel = function (customerId, modalContainer, csrfToken, parentVm) {

    var self = this;

    self.parentCustomerVM = parentVm;
    self.SelectedModel = ko.observable(new CustomerContactModel(customerId));
    self.ContactList = self.parentCustomerVM.dataModel().CustomerContacts;
    self.isAddingContact = true;

    self.toastAndCloseModal = function (message, error) {
        if (message) {
            if (error)
                toastr.error(message);
            else
                toastr.success(message);
        }

        setTimeout(function () {
            $("#modalCloseBtn").trigger("click");
        }, 200);

    };

    self.updateContact = function () {
        var model = self.SelectedModel();

        if (!model.TempID())
            model.TempID(createUUID());

        var unmappedModel = ko.mapping.toJSON(model);
        if (customerId > 0 && model.ID() > 0) {
            // Do a update only. Add new will be fired when customer gets saved 
            self.viewModelHelper.apiPost("api/customers/" + customerId + "/contacts/update",
                unmappedModel, csrfToken, self.successfulUpdate);

        }

        if (self.isAddingContact) {
            // Create a new item, and push it to the list
            var newItem = new CustomerContactModel(customerId);

            newItem.ID(model.ID());
            newItem.TempID(model.TempID());
            newItem.FirstName(model.FirstName());
            //.... rest of the init code

            self.ContactList.push(newItem);

            // Reset this item to a new one
            self.SelectedModel(new CustomerContactModel(customerId));
        }
        self.toastAndCloseModal("");
    };

    self.initialize = function (tempId, isAdding) {
        self.isAddingContact = isAdding;
        self.viewModelHelper.modelErrors(null);
        self.viewModelHelper.serverErrorPresent(false);
        self.viewModelHelper.modelIsValid(true);

        if (!self.isAddingContact) {
            var item = ko.utils.arrayFirst(self.ContactList(), function (c) { return c.TempID() == tempId; });

            if (item == null) {
                self.toastAndCloseModal("Contact not found", true);
                return;
            }
            self.SelectedModel(item);
        }
    };
};
Note that here i am not firing any API calls to retrieve contact details because I already have what  want in the Contacts List in the main customer model.

I'm using knockout utility function ko.utils.arrayFirst() to find the matching contact item. That's why I had TempID above.

3. CustomerContact.cshtml - Partial View
<!-- Modal -->
<div id="CustomerContactModalWindow" class="modal fade">

    <div id="ContactForm" class="modal-content">

        <div data-bind="with: SelectedModel">

            <div class="form-group ">
                <label class="col-md-4 control-label" for="ContactTitle">Title <span class="form-required">*</span></label>
                <div class="col-md-8">
                    <select id="ContactTitle" name="ContactTitle" class="col-md-12 form-control"
                        data-bind="value: ContactTitle, options: $parent.parentCustomerVM.userSalutationsList, optionsText: 'Description', optionsValue: 'ID', optionsCaption: 'Please Select...'"
                        data-required="true" data-msg_empty="Contact Title is required">
                    </select>
                </div>
            </div>
            <div class="form-group ">
                <label class="col-md-4 control-label" for="FirstName">First Name <span class="form-required">*</span></label>
                <div class="col-md-8">
                    <input class="form-control" id="FirstName" name="FirstName" type="text" data-bind="value: FirstName"
                        data-required="true" data-msg_empty="First Name is required" />
                </div>
            </div>

            <!-- Rest of the emelents -->

        </div>

        <!-- // Modal body END -->
        <div class="modal-footer">
            <button class="btn" id="modalCloseBtn" data-dismiss="modal" aria-hidden="true">Close</button>
            <button id="updateContactBtn" class="btn btn-primary" data-bind="click: updateContact">Update</button>
        </div>
    </div>
Note that I have omitted most of the markup related to Bootstrap Modals. Notice I'm calling Customer View Model's $parent.parentCustomerVM.userSalutationsList to get the already loaded user salutations.

4. AddCustomer.cshtml - Partial View
@section scripts
{ 

    <script src="~/Scripts/plugins/jquery/validator/jQuery-Validator.Knockout.js"></script>
    <script src="~/Scripts/plugins/jquery/jquery.simplePagination.js"></script> 
    <script src="~/Scripts/Bindings/Models/Customers/CustomerModels.js"></script>
    <script src="~/Scripts/Bindings/ViewModels/LookupsViewModel.js"></script> 
    <script src="~/Scripts/Bindings/ViewModels/Customers/CreateUpdateCustomerViewModel.js"></script>
    <script src="~/Scripts/Bindings/ViewModels/Customers/CreateUpdateCustomerContactViewModel.js"></script>
    <script src="~/Scripts/plugins/jquery/modal-popups/bootstrap-modal-popover.js"></script>
}
@section ko_apply
{
    var updatingCustomerID = '@id';
    var returnUrl = '@Url.Action("Manage", "Customer", new { id = "" })';
    var csrfToken = $("input[name='__RequestVerificationToken']").val();

    var viewModel = new CreateUpdateCustomerViewModel(updatingCustomerID,returnUrl, csrfToken);
    ko.applyBindings(viewModel, $("#CustomerDetailsForm")[0]); 

    var csrfToken_rates = $("#ContactForm input[name='__RequestVerificationToken']").val();
    var contactViewModel = new CreateUpdateCustomerContactViewModel(updatingCustomerID, '#CustomerContactModalWindow',csrfToken_rates,viewModel);
    ko.applyBindings(contactViewModel, $("#ContactForm")[0]);

    $(document).on("click", ".open-ContactUpdate", function () {
        var updatingContactId = $(this).data('id');
        var updatingContactName = $(this).data('title');
        $("#CustomerContactModalWindow #ID").val( updatingContactId );
        $("#CustomerContactModalWindow #planTitle").text( "Update contact - " + updatingContactName );
        $("#CustomerContactModalWindow #updateContactBtn").text( "Update" );

        contactViewModel.initialize(updatingContactId, false);

    });

    $(document).on("click", ".open-ContactAdd", function () { 
        contactViewModel.SelectedModel(new CustomerContactModel(updatingCustomerID));
        $("#CustomerContactModalWindow #planTitle").text( "Add new contact" );
        $("#CustomerContactModalWindow #updateContactBtn").text( "Add" );
        contactViewModel.initialize(0, true);

    });
}

<div id="CustomerDetailsForm">
  <div  >

        <div data-bind="with: dataModel">

            <!-- Widget : Other Contacts -->
            <div class="widget widget-inverse">

                <div class="widget-head">
                    <div class="row">
                        <div class="col-md-10">
                            <h4 class="heading">Other Contacts</h4>
                        </div>
                        <div class="col-md-2" style="text-align: right">
                            <a id="CustomerContactAdd" href="#CustomerContactModalWindow"
                               class="btn btn-small btn-warning  open-ContactAdd"
                               style="padding: 5px 12px; margin-bottom: 1px" role="button" 
                                            data-toggle="modal">Add Contact</a>

                        </div>
                    </div>
                </div>

                <div class="widget-body"> 
                    <!-- Contacts Table -->
                    <table class="table table-bordered table-striped table-hover margin-bottom-none">
                        <thead>
                            <tr>
                                <th>First Name </th>
                                <th>Last Name </th>
                                <th style="width: 24%">E-Mail </th>
                                <th style="width: 14%">Phone </th>
                                <th style="width: 60px" class="text-center">&nbsp;</th>
                            </tr>

                        </thead>
                        <tbody data-bind="foreach: CustomerContacts">

                            <tr>
                                <td>
                                    <span style="display: none" data-bind="text: TempID"></span>
                                    <span data-bind="text: FirstName"></span>
                                </td>
                                <td>
                                    <span data-bind="text: LastName"></span>
                                </td>
                                <td>
                                    <span data-bind="text: Email"></span>
                                </td>
                                <td>
                                    <span data-bind="text: Phone"></span>
                                </td>
                                <td class="text-center">
                                    <div class="btn-group btn-group-xs ">
                                        <a href="#CustomerContactModalWindow"
                                            role="button"
                                            data-bind="attr: { 'data-id': TempID(), 'data-title': FirstName() }"
                                            class="btn btn-inverse open-ContactUpdate"
                                            data-toggle="modal"><i class="fa fa-pencil"></i></a>
                                    </div>
                                </td>
                            </tr>

                        </tbody>

                    </table>
                    <!-- // Table END -->
                    <div class="row" style="text-align: center;display: none" id="ContactsPagination" >
                        <ul id="paginationHolder"></ul>
                        <div style="display: inline-block; position: relative; height: 24px; width: 24px; top: -24px;" data-bind="loadingWhen: $parent.VMH.isLoading"></div>

                    </div>
                     @*<pre data-bind="text: ko.toJSON($data.CustomerContacts, null, 2)"></pre>*@
                </div>
            </div> 
            <button type="submit" class="btn btn-primary" data-bind="click: $parent.save">Save</button>&nbsp;&nbsp;
 
        </div>
    </div>
    <!-- // Form END -->
</div>
@{Html.RenderPartial("_CustomerContact");}
This is my main customer details add/update view. Note that I have omitted most of the markup and you can clearly see the Contacts List.

Well, thats it. Rest is the API. It's just normal database calls using EF.


Popular posts from this blog

Print a receipt using a Thermal Printer with C#.NET

Automatic redirect upon session timeout using ASP.NET MVC and Javascript