Using an Abstract AngularJS factory to populate a KendoUI DataSource from an OData Endpoint

Since being introduced to AngularJS, I have enjoyed how robust the framework is, and provide a great layer of abstraction to enable client-side unit testing.   Creating an abstract factory that takes in parameters isolates the client-side data access point and makes it easily testable.

My reason for writing this is that I was unable to find any sources that gave a full end-to-end example (let alone integrating a KendoUI datasource with AngularJS and OData).  KendoUI’s datasource transports make it really simple to connect to an OData endpoint, but I want to Angularize my data calls.

Setup

To manage ContentType data, I used a KendoUI grid to handle all CRUD operations from a simple, intuitive interface.  Using Entity Framework, MVC5, WebAPI2, I exposed an OData endpoint at ~/odata/ContentType.

Setting up an OData endpoint through WebAPI is out of the scope of this article and using VisualStudio 2013 makes it quite simple to create default WebAPI/OData controllers.

The main Get() method of my OData controller is setup like this:

[Queryable]
public virtual IHttpActionResult Get(ODataQueryOptions<ContentType> odataQueryOptions)
 {
 var userId = 102; // mock

try
 {
var results = odataQueryOptions.ApplyTo(_uow.Repository<ContentType>()
    .Query()
    .Get()
    .Where(u => u.UserId == userId)
    .OrderBy(o => o.Description)).Cast<ContentType>()
    .Select(x => new ContentTypeDTO()
    {
       //projection goes here
       ContentTypeId = x.ContentTypeId,
       Description = x.Description,
    });

if (odataQueryOptions.SelectExpand != null)
    {
       Request.SetSelectExpandClause(odataQueryOptions.SelectExpand.SelectExpandClause);
    }

return Ok(results, results.GetType());
 }
 catch (Exception ex)
 {
    throw ex;
 }
 }

Because I want things like paging options to be done at the server level, we apply those to our repository calls through .ApplyTo(…).

Keeping in good form, we also used projection to return a DTO, rather than properties of the entire entity.

Since the endpoint is sitting within my application, I ensured it is stateful, that is, user context details such as userId are available (only mocked in this case).

AngularJS

In my Angular environment, I like to stick to SOLID principles, and end up with several files, but everything is organized.

angulardir

The main page simply contains one tag and the setup is all done through JavaScript in the Angular controller:

<span style="font-family: Georgia, 'Times New Roman', 'Bitstream Charter', Times, serif; font-size: 14px; line-height: 1.5em;">
<body ng=app="app">
</span>   <div ng-controller="contentTypesController">
      <div id="grid"></div>
   </div>
</body>

The main magic really occurs in the configuration of the KendoUI datasource – the grid configuration is quite basic (see the end of the contentType.js controller).

app.js:

</pre>
var app = angular.module('app', ['ngResource', 'ngRoute', 'ngSanitize']);

// ng Routes
//app.config(['$routeProvider', function ($routeProvider) {

// $routeProvider.when('/', {
// controller: 'customersController',
// templateUrl: '/app/views/customers.html'
// })
// .otherwise({ redirectTo: '/' });

//}]);

try { angular.module("ngResource") } catch (err) {
 /* failed to require */
 console.log('not loaded');
}

Injected is ngResource, ngRoute (for use later and routes currently commented-out), and ngSanitize.    I’ve got a bit of debugging code near the bottom to show if there is a problem with loading ngResource.

The ContentTypesController contains all of the UI setup code.

contentTypeController.js

<code>app.controller('contentTypeController', ['$scope', '$log', 'abstractFactory3',
    // the abstract data factory accepts controller type parameters for RESTful CRUD

    function ($scope, $log, abstractFactory3) {

        var dataFactory = new abstractFactory3("/odata/ContentType");

        var crudServiceBaseUrl = "/odata/ContentType";

        var dataSource = new kendo.data.DataSource({
            type: "odata",
            transport: {
                read:

                    function (options) {
                        var odataParams = kendo.data.transports["odata"].parameterMap(options.data, "read");

                        dataFactory.getList(odataParams)
                            .success(function (result) {
                                options.success(result);
                            })
                            .error (function (error) {
                                console.log("data error");
                            });

                    },
                update:
                    function (options) {
                        var data = options.data;
                        dataFactory.update(data.ContentTypeId, data)
                            .success(function (result) {
                                options.success(result);
                            })
                            .error(function (error) {
                                console.log("data error");
                            });
                },
                create:
                    function (options) {
                        var data = options.data;
                        data.ContentTypeId = "0";           // required for valid field data
                        dataFactory.insert(data)
                            .success(function (result) {
                                options.success(result);
                            })
                            .error(function (error) {
                                console.log("data error");
                            });
                },
                destroy:
                    function (options) {
                        var data = options.data;
                        dataFactory.remove(data.ContentTypeId)
                            .success(function (result) {
                                options.success(result);
                            })
                            .error(function (error) {
                                console.log("data error");
                            });

                },
                parameterMap: function (options, type) {
                    // this is optional - if we need to remove any parameters (due to partial OData support in WebAPI
                    if (operation !== "read" && options.models) {
                        return JSON.stringify({ models: options });
                    }
                },

            },
            batch: false,
            pageSize: 10,
            serverPaging: true,
            change: function (e) {
                console.log("change: " + e.action);
                // do something with e
            },
            schema: {
                data: function (data) {
                    //console.log(data)
                    return data.value;
                },
                total: function (data) {
                    console.log("count: " + data["odata.count"]);
                    return data["odata.count"];
                },
                model: {
                    id: "ContentTypeId",
                    fields: {
                        ContentTypeId: { editable: false, nullable: true },
                        //UserId: {editable: false, nullable: false },
                        Description: { type: "string", validation: { required: true } },
                        //msrepl_tran_version: { type: "string", validation: { required: true } }
                    }
                }
            },
            error: function (e) {
                //var response = JSON.parse(e.responseText);
                var response = e.status;
                console.log(response);
            }

        });

        $("#grid").kendoGrid({
            dataSource: dataSource,
            pageable: true,
            height: 400,
            toolbar: ["create"],
            columns: [
                        { field: "ContentTypeId", editable: false, width: 90, title: "ID" },
                        { field: "Description", title: "Content Type" },
                        { command: ["edit", "destroy"] }
            ],
            editable: "inline"
        });

    }]);</code>

The abstract factory is injected into the controller.

abstractFactory3.js:

<code>app.factory('abstractFactory3', function ($http) {

    function abstractFactory3(odataUrlBase) {
        this.odataUrlBase = odataUrlBase;
    }

    abstractFactory3.prototype = {
        getList: function (odataOptions) {
            //var result = $http({
            //    url: this.odataUrlBase,
            //    method: 'GET',
            //    params: odataParams
            //});

            return $http.get(this.odataUrlBase, {
                params: odataOptions
            });
        },
        get: function (id, odataOptions) {
            return $http.get(this.odataUrlBase + '/' + id, {
                params: odataOptions
            });
        },
        insert: function (data) {
            return $http.post(this.odataUrlBase, data);
        },
        update: function (id, data) {
            return $http.put(this.odataUrlBase + '(' + id + ')', data);
        },
        remove: function (id) {
            return $http.delete(this.odataUrlBase + '(' + id + ')');
        }
    };

    return abstractFactory3;
});</code>

Since we are not using KendoUI’s datasource default transport functions which take care the data type serialization/formatting, etc. for OData compatibility, we have to do this manually, and send this off to the abstract angular factory that uses the $http service.  I could have also used ngResource for RESTful service interaction support.

It is important to know what the data looks like coming back from the OData service.  In this case, we have to tell the KendoUI datasource to look in data.value, which contains a collection of objects.  This is done in the schema/data section.

If your datasource is populating a grid you and you want to introduce efficient paging, you also need to inform the datasource where to find the total number of items – schema/total.  This value is returned when the query string parameter $inlinecount=allpages exists.

With abstractFactory3 injected into our controller, I looked at the initialization process to be similar to object instantiation with an overloaded constructor that takes in one parameter – the OData route:

<code>var dataFactory = new abstractFactory3("/odata/ContentType");</code>

With that established we can now call individual “methods” on the abstract factory to perform our CRUD operations.

Since we also need to pass valid OData parameters, we need to extract this from KendoUI with the following code:

<code>var odataParams = kendo.data.transports["odata"].parameterMap(options.data, "read");</code>

Also take note of the parameterMap area, where data is stringified.

Conclusion

In this article, we have walked through the setup and configuration of a KendoUI grid, and datasource that is hydrated by a RESTful OData endpoint through AngularJS and  have briefly discussed the complexity in using AngularJS over the default KendoUI data transports to manage data flow to/from a KendoUI grid control.

I suggest using Fiddler to view the data coming to/from the client and setting breakpoints at key areas, looking at the stack to see what is contained in kendo.data.transports, and when parameterMap is called.

In following SOLID principles, we want to isolate the work that each object (file, class, etc.) does.  We have setup our page controller that manages all the Angular/JS lifting, and the abstract factory to manage the data flow.  This factory can be re-used by any controller by simply passing in the appropriate OData endpoint path upon initialization within a controller, rather than duplicating this code in all of our Angular controllers.

I used SQL Server for my data store, and frequently spin up the SQL Server Profiler to see the queries being executed on the database server.

A word of caution in setting up OData endpoints.  You can specify client-side or server-side data filtering.  Remember – you don’t usually want to return all your data from the server then filter on the client-side.

As there may be more efficient ways of doing things, I welcome any feedback and suggestions.

Advertisements