Create, Update, and Delete with KoGrid

In my previous post I demonstrated how to implement server-side paging with KoGrid and Knockout within the context of an ASP.NET web application, and utilizing MVC and WebApi controllers. In this post I’m going to demonstrate how to extend the grid to enable the user to create, update, and delete records.

I’m going to add an ‘action’ column to the grid, with edit and delete buttons for each record. And I’m going to add an add button.

kogrid2

When the user clicks on a record’s edit button, an edit panel is displayed. The save button is initially disabled, and will be enabled if the user changes the details and all fields are valid. When the user clicks on the save button the changes will be saved, the edit panel will be hidden, and the grid will be refreshed.

kogrid3

When the user clicks on the add button, an add panel is displayed. The add button is initially disabled and will be enabled when the user has entered valid values for all fields. When the user clicks on the add button, the new record is added, the add panel is hidden, and the grid is refreshed.

kogrid4

And when the user clicks on a delete button, the record will be deleted and the grid refreshed.

As per my previous post I’m going to present the solution in the following order –

  1. Model
  2. Controller
  3. View
  4. ViewModel

1. Model

Domain Model

The domain model remains unchanged, with a one-to-many relationship between product categories and products.

ProductCategory.cs

using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace WebAPIKo.Models
{
    public class ProductCategory
    {
        [Key]
        [DatabaseGeneratedAttribute(DatabaseGeneratedOption.Identity)]
        public int ProductCategoryId { get; set; }

        [Required]
        [StringLength(100)]
        public string Name { get; set; }

        // Navigational Properties
        public virtual ICollection<Product> Products { get; set; }
    }
}

Product.cs

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace WebAPIKo.Models
{
    public class Product
    {
        [Key]
        [DatabaseGeneratedAttribute(DatabaseGeneratedOption.Identity)]
        public int ProductId { get; set; }

        [Required]
        [StringLength(100)]
        public string Name { get; set; }

        [Required]
        [StringLength(1000)]
        public string Description { get; set; }

        [Required]
        public int ProductCategoryId { get; set; }

        // Navigation properties
        public virtual ProductCategory ProductCategory { get; set; }
    }
}

WebApiDbContext.cs

using System.Data.Entity;

namespace WebAPIKo.Models
{
    public class WebAPIKoDbContext : DbContext
    {
        public virtual DbSet<Product> Products { get; set; }
        public virtual DbSet<ProductCategory> ProductCategories { get; set; }
    }
}

Data Transfer Objects (DTOs)

ProductCategoryDTO.cs

When editing and adding products we need to allow the user to select a product category from a list. We therefore need a ProductCategoryDTO class to transfer product category details to the view model.

namespace WebAPIKo.Models
{
    public class ProductCategoryDTO
    {
        public int ProductCategoryId { get; set; }
        public string Name { get; set; }
    }
}

ProductDTO.cs

I have added ProductCategoryId to the ProductDTO class, as we now need to uniquely identify a product’s category when editing the product.

namespace WebAPIKo.Models
{
    public class ProductDTO
    {
        public int ProductId { get; set; }
        public string Name { get; set; }
        public string Description { get; set; }
        public int ProductCategoryId { get; set; }
        public string ProductCategoryName { get; set; }
    }
}

ProductListDTO.cs

The ProductListDTO class has not changed. The grid still requires a page of products and a count of the total number of products matching the selected filter.

using System.Collections.Generic;

namespace WebAPIKo.Models
{
    public class ProductListDTO
    {
        public int ProductCount { get; set; }
        public List<ProductDTO> PageOfProducts { get; set; }
    }
}

2. Controller

HomeController.cs

The view/view model – ProductKoGrid.cshtml – is served up by the home controller.

using System.Web.Mvc;

namespace WebAPIKo.Controllers
{
    public class HomeController : Controller
    {
        public ActionResult ProductKoGrid()
        {
            return View();
        }
    }
}

ProductController.cs

In addition to GetProducts, the Product controller has the following actions –

  • GetProduct(int id)
    • Queries the domain model for a Product with the specified id.
    • If not found then returns a 404 (not found) response.
    • If found the returns a 200 (OK) response along with a ProductDTO class containing the product details.
  • PutProduct([FromBody]Product product)
    • Updates an existing product.
    • The [FromBody] attribute tells the controller to map the values contained in the request body to a Product object.
    • If the product is not valid a 400 (bad request) response is returned.
    • An attempt is made to save the changes to the product. If successful a 204 (success, no content) response is returned.
    • If a DbUpdateConcurrencyException occurs whilst saving the changes then either a 404 (not found) response is returned if the product does not exist on the database (it may have been deleted by another user), or a 500 (internal server error) is returned if the product does exist.
  • PostProduct([FromBody]Product product)
    • Adds a new product.
    • The [FromBody] attribute tells the controller to map the values contained in the request body to a Product object.
    • If the product is not valid a 400 (bad request) response is returned.
    • An attempt is made to add the product. If successful a 201 (created) response is returned.
  • DeleteProduct(int id)
    • Queries the domain model for a Product with the specified id.
    • If not found then returns a 404 (not found) response.
    • If found then deletes the product and returns a 200 (OK) response.
using System.Data.Entity;
using System.Data.Entity.Infrastructure;
using System.Linq;
using System.Linq.Dynamic;
using System.Net;
using System.Web.Http;
using System.Web.Http.Description;
using WebAPIKo.Models;

namespace WebAPIKo.Controllers
{
    public class ProductsController : ApiController
    {
        private WebAPIKoDbContext db = new WebAPIKoDbContext();

        // GET: api/Product
        public IHttpActionResult GetProducts([FromUri]int page, [FromUri]int pageSize, [FromUri]string filter, [FromUri]string sort)
        {       
            ProductListDTO dto = new ProductListDTO();

            IQueryable<Product> query = db.Products;

            if (filter != null)
            {
                if (filter.Contains(':'))
                {
                    string[] filterArray = filter.Split(':');
                    switch (filterArray[0].ToLower())
                    {
                        case "name":
                            query = query.Where(p => p.Name.Contains(filterArray[1].Trim()));
                            break;
                        case "description":
                            query = query.Where(p => p.Description.Contains(filterArray[1].Trim()));
                            break;
                    }
                }
                else
                {
                    query = query.Where(p => p.Name.Contains(filter.Trim()) || p.Description.Contains(filter.Trim()));
                }
            }

            dto.ProductCount = query.Count();

            query = query.OrderBy(sort == null ? "name asc" : sort).Skip((page - 1) * pageSize).Take(pageSize);

            dto.PageOfProducts = query.Select(p => new ProductDTO()
                {
                    ProductId = p.ProductId,
                    Name = p.Name,
                    Description = p.Description,
                    ProductCategoryId = p.ProductCategoryId,
                    ProductCategoryName = p.ProductCategory.Name
                }
                ).ToList(); 

            return Ok(dto);
        }

        // GET: api/Product/5
        [ResponseType(typeof(Product))]
        public IHttpActionResult GetProduct(int id)
        {
            Product product = db.Products.Find(id);
            if (product == null)
            {
                return NotFound();
            }

            return Ok(new ProductDTO() { ProductId = product.ProductId, Name = product.Name, ProductCategoryId = product.ProductCategoryId });
        }

        // PUT: api/Product/5
        [ResponseType(typeof(void))]
        public IHttpActionResult PutProduct([FromBody]Product product)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            db.Entry(product).State = EntityState.Modified;

            try
            {
                db.SaveChanges();
            }
            catch (DbUpdateConcurrencyException)
            {
                if (!ProductExists(product.ProductId))
                {
                    return NotFound();
                }
                else
                {
                    throw;
                }
            }

            return StatusCode(HttpStatusCode.NoContent);
        }

        // POST: api/Product
        [ResponseType(typeof(Product))]
        public IHttpActionResult PostProduct([FromBody]Product product)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            db.Products.Add(product);
            db.SaveChanges();

            return CreatedAtRoute("DefaultApi", new { id = product.ProductId }, product);
        }

        // DELETE: api/Product/5
        [ResponseType(typeof(Product))]
        public IHttpActionResult DeleteProduct(int id)
        {
            Product product = db.Products.Find(id);
            if (product == null)
            {
                return NotFound();
            }

            db.Products.Remove(product);
            db.SaveChanges();

            return Ok(product);
        }

        protected override void Dispose(bool disposing)
        {
            if (disposing)
            {
                db.Dispose();
            }
            base.Dispose(disposing);
        }

        private bool ProductExists(int id)
        {
            return db.Products.Count(e => e.ProductId == id) > 0;
        }
    }
}

ProductCategoryController.cs

The ProductCategoryController class is an ApiController, containing a single action – GetProductCategories – which returns a list of ProductCategoryDTO objects.

using System.Collections.Generic;
using System.Linq;
using System.Web.Http;
using WebAPIKo.Models;

namespace WebAPIKo.Controllers
{
    public class ProductCategoryController : ApiController
    {
        private WebAPIKoDbContext db = new WebAPIKoDbContext();

        // GET: api/ProductCategory
        public IHttpActionResult GetProductCategories()
        {
            db.Configuration.ProxyCreationEnabled = false;

            List<ProductCategoryDTO> dto = new List<ProductCategoryDTO>();

            dto = db.ProductCategories.Select(pc =>
                new ProductCategoryDTO()
                {
                    ProductCategoryId = pc.ProductCategoryId,
                    Name = pc.Name
                }
                ).ToList();

            return Ok(dto);
        }

        protected override void Dispose(bool disposing)
        {
            if (disposing)
            {
                db.Dispose();
            }
            base.Dispose(disposing);
        }
    }
}

3. View

HTML

When the add or edit panel is displayed, we need to simulate modality by disabling the rest of the page. This is achieved by displaying an opaque <div> over the whole page with a z-order that is greater than all elements on the page except for the panel.

<div id="disabling-div"></div>

I have added a footer to the list panel. The footer contains an add button, which has it’s click event bound to the view models “get” method.

<div class="row">
    <div class="col-md-12">
        <div class="panel panel-primary list-panel" id="list-panel">
            <div class="panel-heading list-panel-heading">
                <h3 class="panel-title list-panel-title">Products</h3>
                <button type="button" class="btn btn-default btn-md refresh-button" data-bind="click: get">
                    <span class="glyphicon glyphicon-refresh" aria-hidden="true"></span> Refresh
                </button>
            </div>
            <div class="panel-body">
                <div class="gridStyle" data-bind="koGrid: gridOptions"></div>
            </div>
            <div class="panel-footer">
                <button type="button" class="btn btn-primary btn-md" data-bind="click: add">
                    <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add
                </button>
            </div>
            <img src="~/Content/Images/ajax-loader.gif" class="loading-indicator" id="loading-indicator" />
        </div>
    </div>
</div>

I have added 2 separate panels, containing the add and edit forms. It does not matter where on the page these panels are placed as initially they will be hidden and then will be displayed at an absolute position using a SlideDown transition.

Each panel is comprised of the following –

  • A close icon in the panel header, which has it’s click event bound to the respective view model’s cancel() method.
  • A form containing form controls that are bound to the appropriate view model’s properties.
  • The id field is hidden in the add panel, and read-only in the edit.
  • A select control is used to display the product category options. The options are populated from the view model’s productCategories field. We will see in the next section that this field is a ko.observableArray containing productCategory objects. The selected value is bound to the view model’s productCategoryId property.
  • A save button bound to the view model’s cancel() method.
  • The add form has an add button bound to the add view model’s add() method. This button is initially disabled, and will be enabled when the user has completed entering valid values.
  • The edit form has an edit button bound to the edit view model’s update() method. This button is initially disabled and will be enabled when the user has changed at least one value, and all values are valid.
<div class="panel panel-info add-panel" id="add-panel">
    <div class="panel-heading">
        <span class="closeIcon"><span class="glyphicon glyphicon-remove" data-bind="click: cancel"></span></span>
        <h2 class="panel-title">Add New Product</h2>
    </div>
    <div class="panel-body">
        <form id="add-form" role="form">
            <div class="form-group">
                <input id="add-id" type="hidden" class="form-control" data-bind="value: productId" />
            </div>
            <div class="form-group">
                <label for="add-name">Name</label>
                <input id="add-name" type="text" class="form-control" data-bind="value: name" placeholder="Product Name" />
            </div>
            <div class="form-group">
                <label for="add-description">Description</label>
                <input id="add-description" class="form-control" data-bind="value: description" placeholder="Description" />
            </div>
            <div class="form-group">
                <label for="add-product-category">Product Category</label>
                <select id="add-product-category"
                        data-bind="options: productCategories, optionsText: 'name', optionsValue: 'productCategoryId',
                           optionsCaption: '-- Select a Product Category --', value: productCategoryId"></select>
            </div>
            <button type="button" class="btn btn-primary" data-bind="click: add, enable: canSave">
                <span class="glyphicon glyphicon-save" aria-hidden="true"></span> Add
            </button>
            <button type="button" class="btn btn-primary" data-bind="click: cancel">
                <span class="glyphicon glyphicon-remove" aria-hidden="true"></span> Cancel
            </button>
        </form>
    </div>
</div>

<div class="panel panel-info edit-panel" id="edit-panel">
    <div class="panel-heading">
        <span class="closeIcon"><span class="glyphicon glyphicon-remove" data-bind="click: cancel"></span></span>
        <h2 class="panel-title">Edit Product</h2>
    </div>
    <div class="panel-body">
        <form id="edit-form" role="form">
            <div class="form-group">
                <label for="edit-id">Id</label>
                <input id="edit-id" type="hidden" class="form-control" data-bind="value: productId" />
                <p data-bind="text: productId"></p>
            </div>
            <div class="form-group">
                <label for="edit-name">Name</label>
                <input id="edit-name" type="text" class="form-control" data-bind="value: name" placeholder="Product Name" />
            </div>
            <div class="form-group">
                <label for="edit-description">Description</label>
                <input id="edit-description" class="form-control" data-bind="value: description" placeholder="Description" />
            </div>
            <div class="form-group">
                <label for="edit-product-category">Product Category</label>
                <select id="add-product-category"
                        data-bind="options: productCategories, optionsText: 'name', optionsValue: 'productCategoryId',
                           optionsCaption: '-- select a Product Category --', value: productCategoryId"></select>
            </div>
            <button type="button" class="btn btn-primary" data-bind="click: update, enable: canSave">
                <span class="glyphicon glyphicon-save" aria-hidden="true"></span> Save
            </button>
            <button type="button" class="btn btn-primary" data-bind="click: cancel">
                <span class="glyphicon glyphicon-remove" aria-hidden="true"></span> Cancel
            </button>
        </form>
    </div>
</div>

CSS

I have made some small changes to the .gridStyle, kg*, and .listPanel styles, which style the grid and grid panel.

The .addPanel and .editPanel styles, as their names suggest style the add and edit panels. The panels are initially hidden and are positioned towards the top of the page. The z-index is set at 9999. This ensures that they are displayed over the disabling div.

The .closeIcon style is used to position remove glyphicons towards the right hand side of the add and edit panel headers. This simulates a modal close button.

The #disabling-div style positions the disabling div across the whole page, sets it opacity to 50%, and it’s z-index to 1001. The div when visible should then be displayed over all page elements except for the add and edit panels.

<style type="text/css">
    select {
        height: 30px;
        display: block;
    }

    .panel-heading {
        padding: 10px 20px;
    }

    .panel-body {
        padding: 20px 20px 10px 20px;
    }

    .gridStyle {
        border: 1px solid rgb(212,212,212);
        width: 100%;
        height: 300px;
        margin: auto;
    }

    .kgColMenu {
        width: 200px;
    }

        .kgColMenu input[type=text] {
            width: 100%;
        }

    .kgColList {
        padding-left: 25px;
    }

    .kgColListItem label {
        width: 100%;
    }

    .row-button {
        margin-top: 6px;
    }

    .closeIcon {
        position: absolute;
        right: 10px;
        top: 10px;
        font-weight: bold;
        font-family: sans-serif;
        cursor: pointer;
    }

    .add-panel, .edit-panel {
        position: absolute;
        top: 105px;
        width: 400px;
        display: none;
        margin: auto;
        left: 50%;
        margin-left: -200px;
        z-index: 9999;
    }

    .list-panel {
        margin-top: 20px;
    }

        .list-panel .panel-heading {
            overflow: auto;
            padding: 5px 20px;
        }

        .list-panel .panel-title {
            float: left;
            margin-top: 10px;
        }

        .list-panel .panel-footer {
            padding: 5px 20px;
        }

        .list-panel .refresh-button {
            float: right;
        }

    .loading-indicator {
        position: absolute;
        left: 50%;
        top: 50%;
        margin-left: -26px;
        margin-top: -26px;
        display: none;
        z-index: 9999;
    }

    #disabling-div {
        display: none;
        z-index: 1001;
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background-color: white;
        opacity: .50;
        filter: alpha(opacity=50);
    }
</style>

4. ViewModel

Notifier

To keep the code modular, I have created a separate view model for each of the add, edit, and list panels. I want to keep these view models independent, with no knowledge of each other.

For example, the edit view model contains an update() method that communicates changes to the controller via an ajax post. After a successful update, we want the product list to be refreshed, and the list view model contains a get() method to achieve this. We could have the edit view model directly call the list view model’s get() method. But the edit view model would now be dependent on the list view model, making changes more difficult to implement.

A simple answer to our problem is provided by Knockout.

ko.subscribable, is the base object that all Knockout observables derive from. It contains a subscribe() method which enables subscribers to register a function to be called when an event occurs. The subscribe() method accepts three parameters –

  • callBack – the function that is called whenever the event notification occurs.
  • target (optional) – defines the value of this in the callback function.
  • event (optional; default is “change”) – the name of the event.

ko.subscribable also contains a notifySubscribers() method which accepts two parameters –

  • valueToNotify (optional) – value to pass through to the subscribers.
  • event – the names of the event.

I have defined a notifier object to be a global instance of ko.subscribable(), and I have defined some event names –

///////////////////////////////////////////////////////////////////
// Notifier
var notifier = new ko.subscribable();
var PRODUCT_UPDATED = "PRODUCT_UPDATED";
var INVOKE_ADD = "INVOKE_ADD";
var INVOKE_EDIT = "INVOKE_EDIT";

And for our example where we want the list view model’s get() method to be called on successful completion of the edit view model’s update() – the list view model registers it’s get() method using the following code –

notifier.subscribe(self.get, null, PRODUCT_UPDATED);

And the edit view model raises the notification on successful completion of the update() method using the following code –

notifier.notifySubscribers(null, PRODUCT_UPDATED);

Transitions

The add and edit panels are initially hidden. When the user chooses to add a new record, or edit an existing record, we need to reveal the relevant panel, whilst displaying the disabling div that obscures the rest of the view.

I have created a transition object to encapsulate this logic. It has 2 methods – show() and hide(), both of which receive a target and an optional callBack function.

///////////////////////////////////////////////////////////////////////////
// Transitions

var transition = {

    duration: 200,

    show: function (target, callBack) {
        if (target.is(':hidden')) {
            $disablingDiv.show();
            target.slideDown(transition.duration, function () {
                target.find('input[type!=hidden]:first').focus();
                if (typeof callBack !== "undefined") {
                    callBack();
                }
            });
        }
        else if (typeof callBack !== "undefined") {
            callBack();
        }
    },

    hide: function (target, callBack) {
        if (target.is(':visible')) {
            $disablingDiv.hide();
            target.slideUp(transition.duration, callBack);
        }
        else if (typeof callBack !== "undefined") {
            callBack();
        }
    }
}

ProductCategory and ProductCategoryList

The productCategoryList object is comprised of a ko.observableArray, and a get() method.

The get() method retrieves product categories via an ajax get call, and for each creates an instance of productCategory and pushes it to the ko.observableArray.

I have added callBack as an optional argument to the get() method, and will be using this to ensure that a complete list of product categories has been populated prior to binding the add and edit view models.

///////////////////////////////////////////////////////////////////
// ProductCategory

var productCategory = function (productCategoryId, name) {
    var self = this;

    // Properties
    self.productCategoryId = productCategoryId;
    self.name = name;
}

var productCategoryList = function () {
    var self = this;

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

    self.get = function (callBack) {
        $.ajax({
            url: '/api/productCategory',
            type: 'get',
            contentType: 'application/json; charset=utf-8',
            success: function (data) {
                $.each(data, function (key, value) {
                    self.productCategories.push(new productCategory(value.ProductCategoryId, value.Name));
                });

                if (typeof callBack !== "undefined") {
                    callBack();
                };
            }
        });
    }
}

Product

The product object type is a data container, with ko.observable properties. The list view model has a ko.observableArray containing product objects, which is bound to the grid. Also, as discussed in the next section, the add product and edit product view models are based on the product type.

///////////////////////////////////////////////////////////////////
// Product

var product = function (data) {
    var self = this;

    self.productId = ko.observable(data.productId || 0);
    self.name = ko.observable(data.name || null);
    self.description = ko.observable(data.description || null);
    self.productCategoryId = ko.observable(data.productCategoryId || 0);
    self.productCategoryName = ko.observable(data.productCategoryName || null);
}

ProductVM

The productVM object type is an extension of product, and is comprised of data, configuration properties, and methods shared by the add and edit product view models.

///////////////////////////////////////////////////////////////////
// ProductVM

var productVM = function (productCategories, panel) {
    var self = this;

    // Functional Inheritance

    ko.utils.extend(self, new product({}));

    // Properties

    self.productCategories = ko.observableArray(productCategories);

    // Validation

    self.name.extend({
        required: true,
        maxLength: 100
    });

    self.description.extend({
        required: true,
        maxLength: 1000
    });

    self.productCategoryId.extend({
        required: true
    });

    self.errors = ko.validation.group([self.productId, self.name, self.description, self.productCategoryId]);

    // State

    self.hasChanged = ko.observable(false);
    self.isSaving = ko.observable(false);

    self.isValid = ko.computed(function () {
        return self.errors().length == 0;
    });

    self.canSave = ko.computed(function () {
        return self.isValid() && self.hasChanged() && !self.isSaving();
    });

    // Subscriptions

    self.name.subscribe(function (value) {
        self.hasChanged(true);
    });

    self.description.subscribe(function (value) {
        self.hasChanged(true);
    });

    self.productCategoryId.subscribe(function (value) {
        self.hasChanged(true);
    });

    // Methods

    self.initialize = function (data) {
        if (data != null) {
            self.productId(data.productId() || 0);
            self.name(data.name() || null);
            self.description(data.description() || null);
            self.productCategoryId(data.productCategoryId() || 0);
        }
        else {
            self.errors.showAllMessages(false);
        }

        self.hasChanged(false);

        self.show();
    };

    self.cancel = function () {
        self.hide();
    };

    self.show = function () {
        transition.show(panel);
    }

    self.hide = function () {
        // Hide and tidy up
        transition.hide(panel, function () {
            self.productId(0);
            self.name(null);
            self.description(null);
            self.hasChanged(false);
            self.errors.showAllMessages(false);
        });
    }
}

productVM is comprised of the following –

  • A constructor that takes 2 arguments – productCategories which is a collection of productCategory objects, and panel which is the JQuery reference to either the add or edit panel.
  • A self.productCategories property of type ko.observableArray. The productCategories argument is passed into it’s constructor, and it is used to populate a select list when the view model is bound to the view.
  • Extension calls which add Knockout validation configuration information to the product properties.
  • A Knockout validation group, self.errors, containing the properties that require validation.
  • Properties used for tracking state –
    • self.hasChanged – tracks whether one or more properties have been changed.
    • self.isSaving – tracks whether the view model is currently saving. This is necessary as the save operations are asynchronous.
    • self.isValid – tracks whether the product is valid. This is a computed based on the number of validation errors in the self.errors validation group.
    • self.canSave – tracks whether the product can be saved, and is computed as self.isValid() && self.hasChanged && !self.isSaving.
  • Subscriptions which assign functions that set self.hasChanged(true) in response to a change to one of the product properties.
  • A self.initialize method that initializes the state of the view model, including setting product properties. The reason I have an initialize method to set the state, rather than using the view model’s constructor, is that I want to create a single instance of each the add and edit view models and bind them against the view once. But then I want to reuse these view models for adding and editing products consecutively without having to rebind.
  • A self.cancel() function which is bound to the add and edit panel cancel buttons. The self.cancel function calls the self.hide() function.
  • A self.show() function which utilizes the transition object to display the panel. I will describe how and when this method gets executed when I discuss the productAddVM and productEditVM implementations.
  • A self.hide() function which utilizes the transition object to hide the panel, and includes some tidy-up code in a callBack function.

ProductAddVM

productAddVM is the view model that is bound to the add product panel. It extends productVM, and adds in functionality that is specific to adding products.

///////////////////////////////////////////////////////////////////
// ProductAddVM

var productAddVM = function (productCategories) {
    var self = this;

    var panel = $addPanel;

    // Functional Inheritance

    ko.utils.extend(self, new productVM(productCategories, panel));

    // Subscriptions

    notifier.subscribe(self.initialize, null, INVOKE_ADD);

    // Methods

    self.add = function () {

        if (self.errors().length == 0) {

            self.isSaving(true);

            var dataObject = ko.toJSON(this);

            $.ajax({
                url: productsUri,
                type: 'post',
                data: dataObject,
                dataType: 'json',
                contentType: 'application/json; charset=utf-8',
                success: function (data) {
                    self.isSaving(false);

                    self.hide();

                    notifier.notifySubscribers(null, PRODUCT_UPDATED);
                }
            });
        }
        else {
            self.errors.showAllMessages();
        }
    };
}

productAddVM is comprised of the following –

  • A constructor that takes a productCategories argument, which is then passed through to the base productVM and assigned to self.productCategories. I have taken the decision to retrieve a set of product categories from the server when the page is initialized, and then pass these into the productAddVM when they are constructed. I am assuming that the product categories will not be changed during the life-time of the page.
  • A JQuery reference to the add panel. This is also passed through to the base productVM and used by the show and hide methods.
  • A notifier subscription, which requests the execution of the self.initialize method when the INVOKE_ADD event notification is triggered. The list view model will trigger this event when the user clicks on the grid’s add button.
  • An update() method which utilizes a ajax put to pass the new product details to the server. On successful execution the method hides the panel and triggers the PRODUCT_UPDATED event notification.

ProductEditVM

productEditVM is bound to the edit panel, and also extends productVM. It contains functionality that is specific to editing products.

///////////////////////////////////////////////////////////////////
// ProductEditVM

var productEditVM = function (productCategories) {
    var self = this;

    var panel = $editPanel;

    // Functional Inheritance

    ko.utils.extend(self, new productVM(productCategories, panel));

    // Subscriptions

    notifier.subscribe(self.initialize, null, INVOKE_EDIT);

    // Methods

    self.update = function () {
        if (self.errors().length == 0) {

            self.isSaving(true);

            var dataObject = ko.toJSON(this);

            $.ajax({
                url: productsUri,
                type: 'put',
                data: dataObject,
                dataType: 'json',
                contentType: 'application/json; charset=utf-8',
                success: function (data) {
                    self.isSaving(false);

                    self.hide();

                    notifier.notifySubscribers(null, PRODUCT_UPDATED);
                }
            });
        }
        else {
            self.errors.showAllMessages();
        }
    };
}

It is comprised of the following –

  • A constructor that takes a productCategories argument, which is passed through to the base productVM.
  • A JQuery reference to the edit panel. This is also passed through to the base productVM and used by the show and hide methods.
  • A notifier subscription, which requests the execution of the self.initialize method when the INVOKE_EDIT event notification is triggered. The list view model will trigger this event when the user clicks on the one of grid’s edit buttons.
  • An update() method which utilizes an ajax post to send product updates to the server. On successful execution the method hides the panel and triggers the PRODUCT_UPDATED event notification.

ProductListVM

prodictListVM  is the view model that is bound to the product grid.

///////////////////////////////////////////////////////////////////
// ProductListVM

var productListVM = function () {
    var self = this;

    // Properties
    self.products = ko.observableArray([]);

    self.columnDefs = [
        { field: 'productId', displayName: 'Id', width: 80 },
        { field: 'name', displayName: 'Name', width: 200 },
        { field: 'description', displayName: 'Description' },
        { field: 'productCategoryName', displayName: 'Product Category', width: 200 },
        { field: 'productId', displayName: ' ', cellTemplate: $('#editDeleteCellTemplate').html(), width: 150, sortable: false }
    ];

    self.filterOptions = {
        filterText: ko.observable(""),
        useExternalFilter: false
    };

    self.pagingOptions = {
        currentPage: ko.observable(1),
        pageSizes: ko.observableArray([2, 5, 10, 20, 50]),
        pageSize: ko.observable(2),
        totalServerItems: ko.observable(0)
    };

    self.sortInfo = ko.observable({ column: { 'field': 'name' }, direction: 'asc' });

    self.gridOptions = {
        data: self.products,
        columnDefs: self.columnDefs,
        autogenerateColumns: false,
        showGroupPanel: true,
        canSelectRows: false,
        showFilter: true,
        filterOptions: self.filterOptions,
        enablePaging: true,
        pagingOptions: self.pagingOptions,
        sortInfo: self.sortInfo,
        rowHeight: 35
    };

    // Subscriptions

    self.pagingOptions.pageSize.subscribe(function (data) {
        self.pagingOptions.currentPage(1);
        self.get();
    });

    self.pagingOptions.currentPage.subscribe(function (data) {
        self.get();
    });

    self.sortInfo.subscribe(function (data) {
        self.pagingOptions.currentPage(1);
        self.get();
    });

    notifier.subscribe(self.get, null, PRODUCT_UPDATED);

    // Methods

    self.get = function () {
        $loadingIndicator.show();

        $.ajax({
            url: productsUri,
            type: 'get',
            data: {
                'page': self.pagingOptions.currentPage(),
                'pageSize': self.pagingOptions.pageSize(),
                'filter': self.filterOptions.filterText == undefined ? '' : self.filterOptions.filterText(),
                'sort': self.sortInfo().column.field + ' ' + self.sortInfo().direction
            },
            contentType: 'application/json; charset=utf-8',
            success: function (data) {
                self.pagingOptions.totalServerItems(data.ProductCount);

                var productsArray = [];
                $.each(data.PageOfProducts, function (key, value) {
                    productsArray.push(
                        new product({
                            productId: value.ProductId,
                            name: value.Name,
                            description: value.Description,
                            productCategoryId: value.ProductCategoryId,
                            productCategoryName: value.ProductCategoryName
                        }));
                });
                self.products(productsArray);

                $loadingIndicator.hide();
            }
        });
    };

    self.add = function () {
        notifier.notifySubscribers(null, INVOKE_ADD);
    };

    self.edit = function (item) {
        notifier.notifySubscribers(item, INVOKE_EDIT);
    };

    self.delete = function (item) {

        bootbox.confirm("Are you sure?", function (result) {
            Example.show("Confirm result: " + result);
        });
        if (confirm("Are you sure you want to delete " + item.name() + "?")) {
            var dataObject = JSON.stringify({ id: item.productId() });

            $.ajax({
                url: productsUri + '/' + item.productId(),
                type: 'delete',
                dataType: 'json',
                contentType: 'application/json; charset=utf-8',
                success: function (data) {
                    self.get();
                }
            });
        }
    }
}

In addition to the functionality described in the previous blog post, productListVM is comprised of the following –

  • An additional column definition, which generates the column containing edit and delete buttons for each product. The column is generated based on a editDeleteCellTemplate column template. this template is outlined in the nest section.
  • A notifier subscription, registering the get() method to be called when the PRODUCT_UPDATED event is invoked. This event is invoked by either the add or edit view model when a product is updated. The grid is therefore refreshed in response to a product being updated.
  • An add() method, which invokes the INVOKE_ADD notification. This method is bound to the add buttons click event.
  • An edit() method, which invokes the INVOKE_EDIT notification. This method is bound to the grid’s edit buttons click event, and passes through the view model item specific to the button that was clicked.
  • A delete() method, which asked for conformation, and then uses an ajax delete call to delete the product. On successfull completion the get() method is called and hence the grid is refreshed.

Edit/Delete Cell Template

The following html template is used by the list view model to generate the column containing the edit and delete buttons –

<script type="text/template" id="editDeleteCellTemplate">
    <div>
        <button type="button" class="btn btn-primary btn-xs row-button" data-bind="click: function() { $parent.$userViewModel.edit($parent.entity); }">
            <span class="glyphicon glyphicon-edit" aria-hidden="true"></span> Edit
        </button>
        <button type="button" class="btn btn-danger btn-xs row-button" data-bind="click: function() { $parent.$userViewModel.delete($parent.entity); }">
            <span class="glyphicon glyphicon-minus" aria-hidden="true"></span> Delete
        </button>
    </div>
</script>

Kicking it all off

First thing I do is create an instance of productListVM, and bind this instance to the list panel.

I then create an instance of productCategoryList, and call the get() method, which gets the product categories from the server.

I pass a callBack function to the productCategoryList.get() method, to ensure that the full list is retrieved prior to performing the following –

  • create a new instance of productAddVM, passing the product category list into the constructor, and binding this instance to the add panel.
  • create a new instance of productEditVM, passing the product category list into the constructor, and binding this instance to the edit panel.
  • and finally, call the prroductListVM.get() method to populate the product grid.
/////////////////////////////////////////////////////////////////
// Let's kick it all off

var productListVM = new productListVM();
ko.applyBindings(productListVM, $listPanel[0]);

var productCategoryList = new productCategoryList();

productCategoryList.get(function () {
    ko.applyBindings(new productAddVM(productCategoryList.productCategories()), $addPanel[0]);
    ko.applyBindings(new productEditVM(productCategoryList.productCategories()), $editPanel[0]);
    productListVM.get();
});

For completeness, here’s the full View Model code –

<script type="text/template" id="editDeleteCellTemplate">
<div>
    <button type="button" class="btn btn-primary btn-xs row-button" data-bind="click: function() { $parent.$userViewModel.edit($parent.entity); }">
        <span class="glyphicon glyphicon-edit" aria-hidden="true"></span> Edit
    </button>
    <button type="button" class="btn btn-danger btn-xs row-button" data-bind="click: function() { $parent.$userViewModel.delete($parent.entity); }">
        <span class="glyphicon glyphicon-minus" aria-hidden="true"></span> Delete
    </button>
</div>
</script>

<script type="text/javascript">
$(function () {
    var $listPanel = $('#list-panel');
    var $addPanel = $('#add-panel');
    var $editPanel = $('#edit-panel');

    var $disablingDiv = $('#disabling-div');
    var $loadingIndicator = $('#loading-indicator');

    var productsUri = '/api/products';

    ///////////////////////////////////////////////////////////////////
    // Notifier
    var notifier = new ko.subscribable();
    var PRODUCT_UPDATED = "PRODUCT_UPDATED";
    var INVOKE_ADD = "INVOKE_ADD";
    var INVOKE_EDIT = "INVOKE_EDIT";

    ///////////////////////////////////////////////////////////////////
    // ProductCategory

    var productCategory = function (productCategoryId, name) {
        var self = this;

        // Properties
        self.productCategoryId = productCategoryId;
        self.name = name;
    }

    var productCategoryList = function () {
        var self = this;

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

        self.get = function (callBack) {
            $.ajax({
                url: '/api/productCategory',
                type: 'get',
                contentType: 'application/json; charset=utf-8',
                success: function (data) {
                    $.each(data, function (key, value) {
                        self.productCategories.push(new productCategory(value.ProductCategoryId, value.Name));
                    });

                    if (typeof callBack !== "undefined") {
                        callBack();
                    };
                }
            });
        }
    }

    ///////////////////////////////////////////////////////////////////
    // Product

    var product = function (data) {
        var self = this;

        self.productId = ko.observable(data.productId || 0);
        self.name = ko.observable(data.name || null);
        self.description = ko.observable(data.description || null);
        self.productCategoryId = ko.observable(data.productCategoryId || 0);
        self.productCategoryName = ko.observable(data.productCategoryName || null);
    }

    ///////////////////////////////////////////////////////////////////
    // ProductVM, ProductAddVM, and ProductEditVM

    var productVM = function (productCategories, panel) {
        var self = this;

        // Functional Inheritance

        ko.utils.extend(self, new product({}));

        // Properties

        self.productCategories = ko.observableArray(productCategories);

        // Validation

        self.name.extend({
            required: true,
            maxLength: 100
        });

        self.description.extend({
            required: true,
            maxLength: 1000
        });

        self.productCategoryId.extend({
            required: true
        });

        self.errors = ko.validation.group([self.productId, self.name, self.description, self.productCategoryId]);

        // State

        self.hasChanged = ko.observable(false);
        self.isSaving = ko.observable(false);

        self.isValid = ko.computed(function () {
            return self.errors().length == 0;
        });

        self.canSave = ko.computed(function () {
            return self.isValid() && self.hasChanged() && !self.isSaving();
        });

        // Subscriptions

        self.name.subscribe(function (value) {
            self.hasChanged(true);
        });

        self.description.subscribe(function (value) {
            self.hasChanged(true);
        });

        self.productCategoryId.subscribe(function (value) {
            self.hasChanged(true);
        });

        // Methods

        self.initialize = function (data) {
            if (data != null) {
                self.productId(data.productId() || 0);
                self.name(data.name() || null);
                self.description(data.description() || null);
                self.productCategoryId(data.productCategoryId() || 0);
            }
            else {
                self.errors.showAllMessages(false);
            }

            self.hasChanged(false);

            self.show();
        };

        self.cancel = function () {
            self.hide();
        };

        self.show = function () {
            transition.show(panel);
        }

        self.hide = function () {
            // Hide and tidy up
            transition.hide(panel, function () {
                self.productId(0);
                self.name(null);
                self.description(null);
                self.hasChanged(false);
                self.errors.showAllMessages(false);
            });
        }
    }

    var productAddVM = function (productCategories) {
        var self = this;

        var panel = $addPanel;

        // Functional Inheritance

        ko.utils.extend(self, new productVM(productCategories, panel));

        // Subscriptions

        notifier.subscribe(self.initialize, null, INVOKE_ADD);

        // Methods

        self.add = function () {

            if (self.errors().length == 0) {

                self.isSaving(true);

                var dataObject = ko.toJSON(this);

                $.ajax({
                    url: productsUri,
                    type: 'post',
                    data: dataObject,
                    dataType: 'json',
                    contentType: 'application/json; charset=utf-8',
                    success: function (data) {
                        self.isSaving(false);

                        self.hide();

                        notifier.notifySubscribers(null, PRODUCT_UPDATED);
                    }
                });
            }
            else {
                self.errors.showAllMessages();
            }
        };
    }

    var productEditVM = function (productCategories) {
        var self = this;

        var panel = $editPanel;

        // Functional Inheritance

        ko.utils.extend(self, new productVM(productCategories, panel));

        // Subscriptions

        notifier.subscribe(self.initialize, null, INVOKE_EDIT);

        // Methods

        self.update = function () {
            if (self.errors().length == 0) {

                self.isSaving(true);

                var dataObject = ko.toJSON(this);

                $.ajax({
                    url: productsUri,
                    type: 'put',
                    data: dataObject,
                    dataType: 'json',
                    contentType: 'application/json; charset=utf-8',
                    success: function (data) {
                        self.isSaving(false);

                        self.hide();

                        notifier.notifySubscribers(null, PRODUCT_UPDATED);
                    }
                });
            }
            else {
                self.errors.showAllMessages();
            }
        };
    }

    ///////////////////////////////////////////////////////////////////
    // ProductListVM

    var productListVM = function () {
        var self = this;

        // Properties
        self.products = ko.observableArray([]);

        self.columnDefs = [
            { field: 'productId', displayName: 'Id', width: 80 },
            { field: 'name', displayName: 'Name', width: 200 },
            { field: 'description', displayName: 'Description' },
            { field: 'productCategoryName', displayName: 'Product Category', width: 200 },
            { field: 'productId', displayName: ' ', cellTemplate: $('#editDeleteCellTemplate').html(), width: 150, sortable: false }
        ];

        self.filterOptions = {
            filterText: ko.observable(""),
            useExternalFilter: false
        };

        self.pagingOptions = {
            currentPage: ko.observable(1),
            pageSizes: ko.observableArray([2, 5, 10, 20, 50]),
            pageSize: ko.observable(2),
            totalServerItems: ko.observable(0)
        };

        self.sortInfo = ko.observable({ column: { 'field': 'name' }, direction: 'asc' });

        self.gridOptions = {
            data: self.products,
            columnDefs: self.columnDefs,
            autogenerateColumns: false,
            showGroupPanel: true,
            canSelectRows: false,
            showFilter: true,
            filterOptions: self.filterOptions,
            enablePaging: true,
            pagingOptions: self.pagingOptions,
            sortInfo: self.sortInfo,
            rowHeight: 35
        };

        // Subscriptions

        self.pagingOptions.pageSize.subscribe(function (data) {
            self.pagingOptions.currentPage(1);
            self.get();
        });

        self.pagingOptions.currentPage.subscribe(function (data) {
            self.get();
        });

        self.sortInfo.subscribe(function (data) {
            self.pagingOptions.currentPage(1);
            self.get();
        });

        notifier.subscribe(self.get, null, PRODUCT_UPDATED);

        // Methods

        self.get = function () {
            $loadingIndicator.show();

            $.ajax({
                url: productsUri,
                type: 'get',
                data: {
                    'page': self.pagingOptions.currentPage(),
                    'pageSize': self.pagingOptions.pageSize(),
                    'filter': self.filterOptions.filterText == undefined ? '' : self.filterOptions.filterText(),
                    'sort': self.sortInfo().column.field + ' ' + self.sortInfo().direction
                },
                contentType: 'application/json; charset=utf-8',
                success: function (data) {
                    self.pagingOptions.totalServerItems(data.ProductCount);

                    var productsArray = [];
                    $.each(data.PageOfProducts, function (key, value) {
                        productsArray.push(
                            new product({
                                productId: value.ProductId,
                                name: value.Name,
                                description: value.Description,
                                productCategoryId: value.ProductCategoryId,
                                productCategoryName: value.ProductCategoryName
                            }));
                    });
                    self.products(productsArray);

                    $loadingIndicator.hide();
                }
            });
        };

        self.add = function () {
            notifier.notifySubscribers(null, INVOKE_ADD);
        };

        self.edit = function (item) {
            notifier.notifySubscribers(item, INVOKE_EDIT);
        };

        self.delete = function (item) {

            bootbox.confirm("Are you sure?", function (result) {
                Example.show("Confirm result: " + result);
            });
            if (confirm("Are you sure you want to delete " + item.name() + "?")) {
                var dataObject = JSON.stringify({ id: item.productId() });

                $.ajax({
                    url: productsUri + '/' + item.productId(),
                    type: 'delete',
                    dataType: 'json',
                    contentType: 'application/json; charset=utf-8',
                    success: function (data) {
                        self.get();
                    }
                });
            }
        }
    }

    ///////////////////////////////////////////////////////////////////////////
    // Transitions

    var transition = {

        duration: 200,

        show: function (target, callBack) {
            if (target.is(':hidden')) {
                $disablingDiv.show();
                target.slideDown(transition.duration, function () {
                    target.find('input[type!=hidden]:first').focus();
                    if (typeof callBack !== "undefined") {
                        callBack();
                    }
                });
            }
            else if (typeof callBack !== "undefined") {
                callBack();
            }
        },

        hide: function (target, callBack) {
            if (target.is(':visible')) {
                $disablingDiv.hide();
                target.slideUp(transition.duration, callBack);
            }
            else if (typeof callBack !== "undefined") {
                callBack();
            }
        }
    }

    /////////////////////////////////////////////////////////////////
    // Let's kick it all off

    var productListVM = new productListVM();
    ko.applyBindings(productListVM, $listPanel[0]);

    var productCategoryList = new productCategoryList();

    productCategoryList.get(function () {
        ko.applyBindings(new productAddVM(productCategoryList.productCategories()), $addPanel[0]);
        ko.applyBindings(new productEditVM(productCategoryList.productCategories()), $editPanel[0]);
        productListVM.get();
    });
});

</script>
Advertisements