This will be part five of a six part series of blog posts.
- Modern Web Application Layered High Level Architecture with SPA, MVC, Web API, EF, Kendo UI, OData
- Generically Implementing the Unit of Work & Repository Pattern with Entity Framework in MVC & Simplifying Entity Graphs
- MVC 4, Kendo UI, SPA with Layout, View, Router & MVVM
- MVC 4, Web API, OData, EF, Kendo UI, Grid, Datasource (CRUD) with MVVM
- MVC 4, Web API, OData, EF, Kendo UI, Binding a Form to Datasource (CRUD) with MVVM
- Upgrading to Async with Entity Framework, MVC, OData AsyncEntitySetController, Kendo UI, Glimpse & Generic Unit of Work Repository Framework v2.0
Update: 09/09/2013 – Sample application and source code has been uploaded to CodePlex: https://genericunitofworkandrepositories.codeplex.com, updated Visual 2013, Twitter Bootstrap, MVC 5, EF6, Kendo UI Bootstrap theme, project redeployed to Windows Azure Website.
Update: 06/20/2013 – Bug fix: productEdit View intermittently failing to update. Enhancement: Added state management for Grid, after productEdit View updates (syncs), will auto navigate back to Grid and re-select the last selected row. Updated blog, sample app download, and live demo.
Just a quick recap on the last post, we wired up the Kendo UI Grid, DataSource with MVVM with all the traditional CRUD functionality. In this blog we’ll cover editing with a form in a Kendo UI View that is remotely loaded into our SPA that is bound the Kendo UI Datasource using MVVM. The View will be loaded in from with a click of a button on the row your are trying to edit from the Kendo UI Grid.
This will be part three of a five part series of blog posts.
- Generically Implementing the Unit of Work & Repository Pattern with Entity Framework in MVC & Simplifying Entity Graphs
- MVC 4, Kendo UI, SPA with Layout, View, Router & MVVM
- MVC 4, Web API, OData, EF, Kendo UI, Grid, Datasource (CRUD) with MVVM
- MVC 4, Web API, OData, EF, Kendo UI, Binding a Form to Datasource (CRUD) with MVVM
- Upgrading to Async with Entity Framework, MVC, OData AsyncEntitySetController, Kendo UI, Glimpse & Generic Unit of Work Repository Framework v2.0
Taking a look at a high level architecture of this three part series blog: Modern Web Application Layered High Level Architecture with SPA, MVC, Web API, EF, Kendo UI.
Live demo: http://longle.azurewebsites.net, courtesy of Windows Azure free 10 Website’s
Spa.Controllers.ProductController.cs
public class ProductController : EntitySetController<Product, int> { private readonly IUnitOfWork _unitOfWork; public ProductController(IUnitOfWork unitOfWork) { _unitOfWork = unitOfWork; } public override IQueryable<Product> Get() { return _unitOfWork.Repository<Product>().Query().Get(); } protected override Product GetEntityByKey(int key) { return _unitOfWork.Repository<Product>().FindById(key); } protected override Product UpdateEntity(int key, Product update) { update.State = ObjectState.Modified; _unitOfWork.Repository<Product>().Update(update); _unitOfWork.Save(); return update; } public override void Delete([FromODataUri] int key) { _unitOfWork.Repository<Product>().Delete(key); _unitOfWork.Save(); } protected override void Dispose(bool disposing) { _unitOfWork.Dispose(); base.Dispose(disposing); } }
Our Web Api OData ProductsController, pretty much the same Controller used in the previous blog to hydrate our Grid along with the other CRUD actions. This Controller will also share the same duties as it did before, for the Grid. Here it handle hydrating the Form, perform updates and deletes.
Adding a custom command Edit button to the Grid (line 32)
Spa/Content/Views/products.html
<script type="text/x-kendo-template" id="products"> <section class="content-wrapper main-content clear-fix"> <h3>Technlogy Stack</h3> <ol class="round"> <li class="one"> <h5>.NET</h5> ASP.NET MVC 4, Web API, OData, Entity Framework </li> <li class="two"> <h5>Kendo UI Web Framework</h5> MVVM, SPA, Grid, DataSource </li> <li class="three"> <h5>Patterns</h5> Unit of Work, Repository, MVVM </li> </ol> <h3>View Products</h3><br/> <div class="k-content" style="width:100%"> <div id="productsForm"> <div id="productGrid" data-role="grid" data-sortable="true" data-pageable="true" data-filterable="true" data-bind="source: dataSource, events:{dataBound: dataBound, change: onChange}" data-editable = "inline" data-selectable="true" data-columns='[ { field: "ProductID", title: "Id", width: "50px" }, { field: "ProductName", title: "Name", width: "300px" }, { field: "UnitPrice", title: "Price", format: "{0:c}", width: "100px" }, { field: "Discontinued", width: "150px" }, { command : [ "edit", "destroy", { text: "Edit Details", click: editProduct } ], title: "Action", } ]'> </div> </div> </div> </section> </script> <script> function editProduct(e) { e.preventDefault(); var tr = $(e.currentTarget).closest("tr"); var dataItem = $("#productGrid").data("kendoGrid").dataItem(tr); window.location.href = '#/productEdit/' + dataItem.ProductID; } var lastSelectedProductId; var crudServiceBaseUrl = "/odata/Product"; var productsModel = kendo.observable({ dataSource: dataSource = new kendo.data.DataSource({ type: "odata", transport: { read: { url: crudServiceBaseUrl, dataType: "json" }, update: { url: function (data) { return crudServiceBaseUrl + "(" + data.ProductID + ")"; }, dataType: "json" }, destroy: { url: function (data) { return crudServiceBaseUrl + "(" + data.ProductID + ")"; }, dataType: "json" } }, batch: false, serverPaging: true, serverSorting: true, serverFiltering: true, pageSize: 10, schema: { data: function (data) { return data.value; }, total: function (data) { return data["odata.count"]; }, errors: function (data) { }, model: { id: "ProductID", fields: { ProductID: { type: "number", editable: false, nullable: true }, ProductName: { type: "string", validation: { required: true } }, UnitPrice: { type: "number", validation: { required: true, min: 1 } }, Discontinued: { type: "boolean" }, UnitsInStock: { type: "number", validation: { min: 0, required: true } } } } }, error: function (e) { var message = e.xhr.responseJSON["odata.error"].message.value; var innerMessage = e.xhr.responseJSON["odata.error"].innererror.message; alert(message + "\n\n" + innerMessage); } }), dataBound: function (arg) { if (lastSelectedProductId == null) return; // check if there was a row that was selected var view = this.dataSource.view(); // get all the rows for (var i = 0; i < view.length; i++) { // iterate through rows if (view[i].ProductID == lastSelectedProductId) { // find row with the lastSelectedProductd var grid = arg.sender; // get the grid grid.select(grid.table.find("tr[data-uid='" + view[i].uid + "']")); // set the selected row break; } } }, onChange: function (arg) { var grid = arg.sender; var dataItem = grid.dataItem(grid.select()); lastSelectedProductId = dataItem.ProductID; } }); $(document).bind("viewSwtichedEvent", function (e, args) { // subscribe to the viewSwitchedEvent if (args.name == "products") { // check if this view was switched too if (args.isRemotelyLoaded) { // check if this view was remotely loaded from server kendo.bind($("#productsForm"), productsModel); // bind the view to the model } else {// view already been loaded in cache productsModel.dataSource.fetch(function() {}); // refresh grid } } }); </script>
Additions to Client-Side
editProduct
function editProduct(e) { e.preventDefault(); var tr = $(e.currentTarget).closest("tr"); var dataItem = $("#productGrid").data("kendoGrid").dataItem(tr); window.location.href = '#/productEdit/' + dataItem.ProductID; }
The editProduct method will handle extracting the ProductID of the row we clicked “Edit Details” and navigating to the ProductEdit View.
Additions to the Observable Model
dataBound
dataBound: function (arg) { if (lastSelectedProductId == null) return; // check if there was a row that was selected var view = this.dataSource.view(); // get all the rows for (var i = 0; i < view.length; i++) { // iterate through rows if (view[i].ProductID == lastSelectedProductId) { // find row with the lastSelectedProductd var grid = arg.sender; // get the grid grid.select(grid.table.find("tr[data-uid='" + view[i].uid + "']")); // set the selected row break; } }
The dataBound (delgate) event handler will be responsible for re-selecting the last selected row in the Grid before we navigated away from the Grid to the productEdit View.
onChange
onChange: function (arg) { var grid = arg.sender; var dataItem = grid.dataItem(grid.select()); lastSelectedProductId = dataItem.ProductID; }
The onChange (delegate) event handler will be responsible for saving off the last selected rows ProductID so that if we navigate to the ProductEdit View and back we can maintain the last selected row state.
Creating the ProductEdit View
Spa/Content/Views/productEdit.html
(styles have been omitted, for clarity)
<!-- styles remove for clarity --> <script type="text/x-kendo-template" id="productEdit"> <section class="content-wrapper main-content clear-fix"> <div class="k-block" style="width:600px; margin-top:35px"> <div class="k-block k-info-colored"> <strong>Note: </strong>Please fill out all of the fields in this form. </div> <div id="product-edit-form"> <dl> <dt> <label for="firstName">Product Name:</label></dt> <dd> <span class="k-textbox k-space-right"> <input id="productName" type="text" data-bind="value: ProductName" /> <a href="#" data-field="productName" data-bind="click: clear" class="k-icon k-i-close"> </a> </span> </dd> <dt> <label for="lastName">English Name:</label></dt> <dd> <span class="k-textbox k-space-right"> <input id="englishName" type="text" data-bind="value: EnglishName" /> <a href="#" data-field="englishName" data-bind="click: clear" class="k-icon k-i-close"> </a> </span> </dd> <dt> <label for="quanityPerUnit">Quanity Per Unit:</label></dt> <dd> <span class="k-textbox k-space-right"> <input id="quanityPerUnit" type="text" data-bind="value: QuantityPerUnit" /> <a href="#" data-field="quanityPerUnit" data-bind="click: clear" class="k-icon k-i-close"> </a> </span> </dd> <dt> <label for="unitPrice">Unit Price:</label></dt> <dd> <span class="k-textbox k-space-right"> <input id="unitPrice" type="text" data-bind="value: UnitPrice" /> <a href="#" data-field="unitPrice" data-bind="click: clear" class="k-icon k-i-close"> </a> </span> </dd> <dt> <label for="unitPrice">Unit In Stock:</label></dt> <dd> <span class="k-textbox k-space-right"> <input id="unitsInStock" type="text" data-bind="value: UnitsInStock" /> <a href="#" data-field="unitsInStock" data-bind="click: clear" class="k-icon k-i-close"> </a> </span> </dd> <dt> <label for="unitsOnOrder">Unit On Order:</label></dt> <dd> <span class="k-textbox k-space-right"> <input id="unitsOnOrder" type="text" data-bind="value: UnitsOnOrder" /> <a href="#" data-field="unitsOnOrder" data-bind="click: clear" class="k-icon k-i-close"> </a> </span> </dd> <dt> <label for="reorderLevel">Reorder Level:</label></dt> <dd> <span class="k-textbox k-space-right"> <input id="reorderLevel" type="text" data-bind="value: ReorderLevel" /> <a href="#" data-field="reorderLevel" data-bind="click: clear" class="k-icon k-i-close"> </a> </span> </dd> <dt> <label for="discontinued">Discontinued:</label></dt> <dd> <select id="discontinued" data-role="dropdownlist"> <option value="1">Yes</option> <option value="2">No</option> </select> </dd> <dt> <label for="Recieved">Recieved:</label></dt> <dd> <input data-role="datepicker" id="recieved"> </dd> </dl> <a class="k-button" data-bind="click: saveProduct"><span span class="k-icon k-i-tick"></span> Submit</a> <a class="k-button" data-bind="click: cancel"><span span class="k-icon k-i-tick"></span> Cancel</a> </div> </div> </section> </script> <script> var getProductId = function () { // parse for ProductId from url var array = window.location.href.split('/'); var productId = array[array.length - 1]; return productId; }; var crudServiceBaseUrl = "/odata/Product"; $(document).bind("viewSwtichedEvent", function (e, args) { // subscribe to viewSwitchedEvent if (args.name == "productEdit") { // check if this view was switched to var productModel = kendo.data.Model.define({ // we want to refresh this view anytime its switched to id: "ProductID", fields: { ProductID: { type: "number", editable: false, nullable: true }, ProductName: { type: "string", validation: { required: true } }, EnglishName: { type: "string", validation: { required: true } }, UnitPrice: { type: "number", validation: { required: true, min: 1 } }, Discontinued: { type: "boolean" }, UnitsInStock: { type: "number", validation: { min: 0, required: true } } }, saveProduct: function (e) { e.preventDefault(); dataSource.sync(); window.location.href = '/index.html#/products'; }, cancel: function (e) { e.preventDefault(); window.location.href = '/index.html#/products'; } }); var dataSource = new kendo.data.DataSource({ type: "odata", transport: { read: { url: function (data) { return crudServiceBaseUrl + "(" + getProductId() + ")"; }, dataType: "json" }, update: { url: function (data) { delete data.guid; delete data["odata.metadata"]; return crudServiceBaseUrl + "(" + getProductId() + ")"; }, contentType: "application/json", type: "PUT", dataType: "json" }, create: { url: crudServiceBaseUrl, dataType: "json" }, destroy: { url: function (data) { return crudServiceBaseUrl + "(" + getProductId() + ")"; }, dataType: "json" }, parameterMap: function (data, operation) { if (operation == "update") { delete data.guid; delete data["odata.metadata"]; data.UnitPrice = data.UnitPrice.toString(); } return JSON.stringify(data); } }, sync: function (e) { window.location.href = '/index.html#/products'; }, batch: false, schema: { type: "json", data: function (data) { delete data["odata.metadata"]; return data; }, total: function (data) { return 1; }, model: productModel } }); dataSource.fetch(function() { if (dataSource.view().length > 0) { kendo.bind($("#product-edit-form"), dataSource.at(0)); } }); } }); </script>
The ProductEdit View will be bound to the Kendo Observable Model that the Datasource will return. It’s called Observable Model because the there is two-binding between the Form and the Model, meaning when a change happens in the Form, it is automatically synced with the Model, which is bound to the Datasource.
You can essentially setup auto-sync on the Datasource so that when there are changes, it will automatically sync back to our OData Web API ProductsController. However for purposes of this post we will stick to a manual sync when we are ready to send our updates to our Controller.
product-edit-form (DIV)
<div id="product-edit-form"> <dl> <dt> <label for="firstName">Product Name:</label></dt> <dd> <span class="k-textbox k-space-right"> <input id="productName" type="text" data-bind="value: ProductName" /> <a href="#" data-field="productName" data-bind="click: clear" class="k-icon k-i-close"> </a> </span> </dd> <dt> <label for="lastName">English Name:</label></dt> <dd> <span class="k-textbox k-space-right"> <input id="englishName" type="text" data-bind="value: EnglishName" /> <a href="#" data-field="englishName" data-bind="click: clear" class="k-icon k-i-close"> </a> </span> </dd> <dt> <label for="quanityPerUnit">Quanity Per Unit:</label></dt> <dd> <span class="k-textbox k-space-right"> <input id="quanityPerUnit" type="text" data-bind="value: QuantityPerUnit" /> <a href="#" data-field="quanityPerUnit" data-bind="click: clear" class="k-icon k-i-close"> </a> </span> </dd> <dt> <label for="unitPrice">Unit Price:</label></dt> <dd> <span class="k-textbox k-space-right"> <input id="unitPrice" type="text" data-bind="value: UnitPrice" /> <a href="#" data-field="unitPrice" data-bind="click: clear" class="k-icon k-i-close"> </a> </span> </dd> <dt> <label for="unitPrice">Unit In Stock:</label></dt> <dd> <span class="k-textbox k-space-right"> <input id="unitsInStock" type="text" data-bind="value: UnitsInStock" /> <a href="#" data-field="unitsInStock" data-bind="click: clear" class="k-icon k-i-close"> </a> </span> </dd> <dt> <label for="unitsOnOrder">Unit On Order:</label></dt> <dd> <span class="k-textbox k-space-right"> <input id="unitsOnOrder" type="text" data-bind="value: UnitsOnOrder" /> <a href="#" data-field="unitsOnOrder" data-bind="click: clear" class="k-icon k-i-close"> </a> </span> </dd> <dt> <label for="reorderLevel">Reorder Level:</label></dt> <dd> <span class="k-textbox k-space-right"> <input id="reorderLevel" type="text" data-bind="value: ReorderLevel" /> <a href="#" data-field="reorderLevel" data-bind="click: clear" class="k-icon k-i-close"> </a> </span> </dd> <dt> <label for="discontinued">Discontinued:</label></dt> <dd> <select id="discontinued" data-role="dropdownlist"> <option value="1">Yes</option> <option value="2">No</option> </select> </dd> <dt> <label for="Recieved">Recieved:</label></dt> <dd> <input data-role="datepicker" id="recieved"> </dd> </dl> <a class="k-button" data-bind="click: saveProduct"><span span class="k-icon k-i-tick"></span> Submit</a> <a class="k-button" data-bind="click: cancel"><span span class="k-icon k-i-tick"></span> Cancel</a> </div>
Notice how everything that needs to bound is using the attributes prefixed with “data-“. This is what the Kendo Web MVVM Framework will scan for when when binding a View with a Model, long story short, this is how you specify the binding mapping options for the following:
- Widget Type (e.g. Grid, TreeView, Calendar, DropDownList, etc.)
- Widget Properties (Attributes)
- Binding Type (e.g. value, click, text, etc.)
- Binding Property from Model (e.g. firstName, lastName, productDatasource, etc.)
- Binding Methods from Model (e.g. openWindow, cancel, sendEmail, etc.)
Client Side & Product Datasource
<script> var getProductId = function () { // parse for ProductId from url var array = window.location.href.split('/'); var productId = array[array.length - 1]; return productId; }; var crudServiceBaseUrl = "/odata/Product"; $(document).bind("viewSwtichedEvent", function (e, args) { // subscribe to viewSwitchedEvent if (args.name == "productEdit") { // check if this view was switched to var productModel = kendo.data.Model.define({ // we want to refresh this view anytime its switched to id: "ProductID", fields: { ProductID: { type: "number", editable: false, nullable: true }, ProductName: { type: "string", validation: { required: true } }, EnglishName: { type: "string", validation: { required: true } }, UnitPrice: { type: "number", validation: { required: true, min: 1 } }, Discontinued: { type: "boolean" }, UnitsInStock: { type: "number", validation: { min: 0, required: true } } }, saveProduct: function (e) { e.preventDefault(); dataSource.sync(); window.location.href = '/index.html#/products'; }, cancel: function (e) { e.preventDefault(); window.location.href = '/index.html#/products'; } }); var dataSource = new kendo.data.DataSource({ type: "odata", transport: { read: { url: function (data) { return crudServiceBaseUrl + "(" + getProductId() + ")"; }, dataType: "json" }, update: { url: function (data) { delete data.guid; delete data["odata.metadata"]; return crudServiceBaseUrl + "(" + getProductId() + ")"; }, contentType: "application/json", type: "PUT", dataType: "json" }, create: { url: crudServiceBaseUrl, dataType: "json" }, destroy: { url: function (data) { return crudServiceBaseUrl + "(" + getProductId() + ")"; }, dataType: "json" }, parameterMap: function (data, operation) { if (operation == "update") { delete data.guid; delete data["odata.metadata"]; data.UnitPrice = data.UnitPrice.toString(); } return JSON.stringify(data); } }, sync: function (e) { window.location.href = '/index.html#/products'; }, batch: false, schema: { type: "json", data: function (data) { delete data["odata.metadata"]; return data; }, total: function (data) { return 1; }, model: productModel } }); dataSource.fetch(function() { if (dataSource.view().length > 0) { kendo.bind($("#product-edit-form"), dataSource.at(0)); } }); } }); </script>
The Product Datasource is responsible for loading the Product details and providing a Observable Model the Form can bind to. It will handle all the rest of the CRUD activities such as updating and deleting the Product. All of the CRUD activities handled by the Datasource will happen over REST using the OData protocol asynchronously.
Client Side Code
Parsing the ProductId From the URL
var getProductId = function () { // parse for ProductId from url var array = window.location.href.split('/'); var productId = array[array.length - 1]; return productId; };
This code pretty much speaks for itself, we are simply parsing the Url to get the ProductId of the Product we are loading and binding to the View.
productModel (Observable Model)
var productModel = kendo.data.Model.define({ // we want to refresh this view anytime its switched to id: "ProductID", fields: { ProductID: { type: "number", editable: false, nullable: true }, ProductName: { type: "string", validation: { required: true } }, EnglishName: { type: "string", validation: { required: true } }, UnitPrice: { type: "number", validation: { required: true, min: 1 } }, Discontinued: { type: "boolean" }, UnitsInStock: { type: "number", validation: { min: 0, required: true } } }, saveProduct: function (e) { e.preventDefault(); dataSource.sync(); window.location.href = '/index.html#/products'; }, cancel: function (e) { e.preventDefault(); window.location.href = '/index.html#/products'; } });
This is how we set up our Observable Product Model that will be returned from the Datasource and bound to the View. We can see here we define the primary key field (property), fields, and methods that are View buttons will bind to. When the saveProduct method is invoked, we will perform a sync, meaning all changes in the dataSource will be sent back to the server side for processing when this is invoked. Because our Model is an Observable Model, and there is two-way binding between the Model and the Datasource (as mentioned earlier), the Datasource is keeping track and knows of all the changes that are happening.
Notice how the cancel method is a redirect with the hash (#) in it, so when the redirect happens the Kendo Router will process this and have our SPA load in the Products View which is the Grid with the Product listing.
Decomposing the Datasource Configuration
paramaterMap
parameterMap: function (data, operation) { if (operation == "update") { delete data.guid; delete data["odata.metadata"]; data.UnitPrice = data.UnitPrice.toString(); } return JSON.stringify(data); }
The parameterMap purpose is so that we can intercept and perform any pre-processing on the payload before it is sent to our Controller.
We are deleting all the properties that are not needed by our Controller, more importantly, we are doing this so that we don’t have any extra properties that are not on our Product Model or Entity, so that the MVC ModelBinder will recognize our payload and bind it to the Product parameter on our UpdateEntity(int key, Product update) method on our ProductController.
We are also converting the UnitPrice to a string before we sending back to the server side, because the UnitPrice type is decimal, and currently when using Web Api and OData, the MVC out of the box ModelBinder is not smart enough (yet) to convert a number to decimal in the ModelBinding process. Ironically, it is smart enough to convert to decimal if we send it as a string, so that’s what we’ll send of for now.
sync
sync: function (e) { window.location.href = '/index.html#/products'; },
The sync event is raised after the changes have been saved on the server side, once this is complete we simply navigate back to the Products Grid.
schema
schema: { type: "json", data: function (data) { delete data["odata.metadata"]; return data; }
Here we are simply transforming the payload before binding it to the Form, we are removing the data.odata.metadata property since it’s really not needed and unpacking the data.
total
total: function (data) { return 1; }
The total defined method here is simply returning the count of how many records where returned from the server, we are always returning 1 here, since this is a form bound to a single Product at all times. You can add some null checking here to return 0 or 1.
dataSource.fetch(callback)
dataSource.fetch(function() { if (dataSource.view().length > 0) { kendo.bind($("#product-edit-form"), dataSource.at(0)); } });
This will invoke the Datasource to make a call to our Controller Get() method, and load the Product, notice how we are passing in a callback so that when the loading is complete (because it’s happening asynchronously) we are then setting to the variable productEditModel because this is the convention we need to follow mentioned in the previous posts (e.g. view, viewModel, view.html). Because we are following these conventions, our implantation in the Index.html view will work off of these conventions and bind the View to the correct Model for us.
There you have it, MVC 4, Web API, OData, EF, Kendo UI, Binding a Form to Datasource (CRUD) with MVVM – Part 3.
Live demo: http://longle.azurewebsites.net
Happy Coding…! 🙂
Download sample application: https://genericunitofworkandrepositories.codeplex.com