Click here to Skip to main content
14,297,239 members

Implement WebBinding using AngularJS

Rate this:
5.00 (14 votes)
Please Sign up or sign in to vote.
5.00 (14 votes)
26 Feb 2015CPOL
This article shows how we can implement WebBinding for the AngularJS library and, use it for binding AngularJS client side objects to ASP.NET server side objects.

AngularJS WebBinding

Table of Contents

Introduction

In the previous article (WebBinding - How to Bind Client JavaScript Objects to Server .NET Objects), we implemented a generic solution for binding .NET server objects to JavaScript client objects. For that solution, we provided a dedicate implementation for the Knockout library. Since AngularJS became a very popular library, I felt the need for providing an implementation for that library too.

At first look, it looks very easy. Just implement few little functions with the dedicate implementation for the wanted library and, you have a working WebBinding for that library. That sounds very simple. That is also what I thought when implementing the WebBinding solution. But, as we will see along this article, for our case (the AngularJS library), it isn't as simple as it sounds.

Background

In order to enable dedicate implementation for a specific library, the WebBinding client script is provided with a set of functions that should be overridden:

  • createObjectHolder: Create a property's object-holder (an object that handles the access to the property's value).
  • createArrayHolder: Create an object-holder for a property that holds an array.
  • getObjectValue: Get a property's value from a property's object-holder.
  • getArrayValue: Get an array property's value from an array property's object-holder.
  • setObjectValue: Set a property's value using a property's object-holder.
  • setArrayValue: Set an array property's value using an array property's object-holder.
  • registerForPropertyChanges: Set a function that should be called when a property's value is changed.
  • registerForArrayChanges: Set a function that should be called when an array property's value is changed.

In some cases (like the one in this article), we may want to change the behavior of the other public functions too:

  • addBindingMapping: Construct a binding's client model for a given binding's mapping.
  • applyBindings: Register the constructed binding's client models for changes notifications.
  • beginServerChangesRequests: Start the binding's communication.
  • createBoundObjectForPropertyPath: Create a client-side object that is bound with the WebBinding mechanism.

This article shows how we can implement WebBinding for the AngularJS library, by implementing those functions with the appropriate dedicated code (without changing anything in the original WebBinding's code).

This article assumes a basic familiarity with the JavaScript language and the AngularJS library. Some parts of our implementation require some deeper understanding on some of the Angular's internals. We'll mention each issue in its place.

How It Works

Object-holders for AngularJS Objects

Wrap AngularJS scope's property with an object

When I developed the WebBinding solution, I worked with the Knockout library. In the Knockout library, every property (that we want to apply binding on) is wrapped with an observable object. Using that object, we can get the property's value, set the property's value (and, notify about the property's change) and, subscribe for changes on the property. Following that design, I designed the WebBinding's generic implementation, to be based on object-holders (observable objects that wrap the relevant properties) that handle the access to the properties' values.

Using Knockout, since the properties are observable values' wrappers, we use the properties themselves as the object-holders. Using AngularJS, the things are different. In the AngularJS library, we have a scope object with regular properties that can be accessed using Angular expressions. In order to implement WebBinding for AngularJS, we need object-holders (observable values' wrappers) for AngularJS too.

In order to achieve that goal, we create:

  1. An object for holding the bound Angular scope (the root object of the binding) and, an object for containing the corresponding object-holders (for the scope's properties):
    function RootObjectWrapper(rootObj) {
        this.orgRootObj = rootObj;
        this.wrapperRootObj = {};
    }
  2. An object for implementing an object-holder for a scope's property:
    function PropertyObjectHolder() {
        this.pathExp = "";
        this.rootObj = {};
        this.isArray = false;
    
        this.innerValue = null;
    }

In the PropertyObjectHolder object, we store the root object and, the expression for the relevant property. We can set the appropriate property's expression, for each object-holder, by going over the whole of the PropertyObjectHolder properties, starting from the RootObjectWrapper object and, build an expression according to the properties' tree as follows:

function validateRootObjectWrappers() {
    for (var objWrapInx = 0; objWrapInx < rootObjectWrappers.length; objWrapInx++) {
        var objWrapper = rootObjectWrappers[objWrapInx];
        if (objWrapper instanceof RootObjectWrapper) {
            objWrapper.validateProperties();
        }
    }
}

RootObjectWrapper.prototype.validateProperties = function () {
    for (var prop in this.wrapperRootObj) {
        var objHolder = this.wrapperRootObj[prop];
        if (objHolder instanceof PropertyObjectHolder) {
            objHolder.validateProperties(this.orgRootObj, prop);
        }
    }
};

PropertyObjectHolder.prototype.validateProperties = function (rootObj, pathExpression) {
    this.rootObj = rootObj;
    this.pathExp = pathExpression;

    if (this.isArray) {
        if (this.innerValue instanceof Array) {
            for (var elemInx = 0; elemInx < this.innerValue.length; elemInx++) {
                var objHolder = this.innerValue[elemInx];
                if (objHolder instanceof PropertyObjectHolder) {
                    var subPropExp = pathExpression + '[' + elemInx + ']';
                    objHolder.validateProperties(rootObj, subPropExp);
                }
            }
        }
    } else {
        if (this.innerValue) {
            for (var prop in this.innerValue) {
                var objHolder = this.innerValue[prop];
                if (objHolder instanceof PropertyObjectHolder) {
                    var subPropExp = pathExpression + '.' + prop;
                    objHolder.validateProperties(rootObj, subPropExp);
                }
            }
        }
    }
};

Get properties' values

Parse Angular expressions

After building the object-holders tree for our scope, since each object-holder contains the scope object and an appropriate Angular expression (for the specific property), all we need for getting the property's value is a way to parse an Angular expression for a given scope. Fortunately, we already have this mechanism inside the AngularJS library. Using the Angular's $parse service, we can get functions for getting and setting properties' values.

Using the $parse service, we can get a getter function using an Angular expression and, get the property's value using the getter function with the appropriate object as follows:

var getter = $parse(expression);
var propertyValue = getter(scopeObject);

For setting properties' values, we can get a setter function using the getter function and, set the property's value as follows:

var getter = $parse(expression);
var setter = getter.assign;
setter(scopeObject, propertyValue);
AngularJS dependency injection

In the previous section, we talked about the $parse service. But, what is that $parse? How can we get it? Typically, we can get it by adding a parameter named $parse to our Angular components' (controller, directive, etc...) constructor functions. When AngularJS constructs our components, it injects the wanted services to the appropriate parameters. For more details about Angular's dependency injection and how it works, you can visit the following links:

Since our WebBinding implementation isn't constructed by AngularJS, we have to inject the $parse service manually. For doing that, we can get the angular injector using the ng module and, use it for getting the $parse service as follows:

var angInjector = angular.injector(["ng"]);
var angParser = angInjector.get("$parse");
Implement the getter function

After we have the $parse service, we can use it for getting the needed properties' values. We can do that as follows:

  1. Set the getter function for each property:
    PropertyObjectHolder.prototype.getGetterFunction = function() {
        var ret = angParser(this.pathExp);
        return ret;
    };
    
    PropertyObjectHolder.prototype.validateProperties = function (rootObj, pathExpression) {
        // ...
    
        this.getterFn = this.getGetterFunction();
    
        // ...
    };
  2. Implement a function for getting property's value, for each object-holder:
    PropertyObjectHolder.prototype.getValue = function () {
        var res = "";
    
        if (this.isOfSimpleType()) {
            if (this.validate()) {
                res = this.getterFn(this.rootObj);
            }
        } else {
            res = this.innerValue;
        }
    
        return res;
    };
    
    PropertyObjectHolder.prototype.validate = function () {
        if (!this.isValid()) {
            validateRootObjectWrappers();
    
            if (!this.isValid()) {
                /*--- The object is still invalid, after the validation... ---*/
                return false;
            }
        }
    
        return true;
    };
    
    PropertyObjectHolder.prototype.isValid = function () {
        if (!this.rootObj || !this.pathExp || this.pathExp.length == 0) {
            return false;
        }
    
        return true;
    };
    
    PropertyObjectHolder.prototype.isOfSimpleType = function () {
        if (this.isArray || this.hasPropertyObjectHolderProperties()) {
            return false;
        }
    
        if (this.innerValue) {
            return isSimpleType(this.innerValue);
        }
    
        return true;
    };
    
    PropertyObjectHolder.prototype.hasPropertyObjectHolderProperties = function () {
        if (!this.innerValue) {
            return false;
        }
    
        for (var prop in this.innerValue) {
            if (this.innerValue[prop] instanceof PropertyObjectHolder) {
                return true;
            }
        }
    
        return false;
    };
    
    function isSimpleType(val) {
        return typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean';
    }

In the isOfSimpleType function, we check if the type of the property that is wrapped by the object-holder, is of simple type. For our case, every object-holder that doesn't hold an array and, doesn't contain internal object-holders and, its wrapped propery is of a native type (string, number or, boolean), is considered as a simple type.

In the getValue function, we get the property's value, according to its type. If the value's type is of a simple type, we return the result of the getter function (retrieved by $parse). Else (if the value's type isn't of a simple type), we return the inner object of the object-holder. This inner object contains the inner object-holders (for sub-properties or, for an array's elements), for the wrapped property.

Set properties' values

Apply values changes to AngularJS

For getting properties' values, we simply called the getter function that is retrieved by the $parse service. For setting properties' values, we can call the corresponding (retrieved by the assign property of the getter function) setter function. But, when setting values to our scope's properties, we usually want the changes to be reflected to the bound DOM elements too.

Usually, when using AngularJS components, that's done transparently. But, how is it done? All of the magic is in the scope's $apply and $digest functions. When running our code using AngularJS (e.g. by an ng-click directive), our code is wrapped using the scope's $apply function. This function runs our code and calls the scope's $digest function, in order to reflect our changes to the relevant bindings. When our code isn't run by AngularJS (like in our case), we should call the $apply function manually.

Implement the setter function

Using the $parse service and the $apply function, we can set the needed properties' values. That can be done as follows:

  1. Set the setter function for each property:
    PropertyObjectHolder.prototype.getSetterFunction = function () {
        var getter = angParser(this.pathExp);
        return getter.assign;
    };
    
    PropertyObjectHolder.prototype.validateProperties = function (rootObj, pathExpression) {
        // ...
    
        this.setterFn = this.getSetterFunction();
    
        // ...
    };
  2. Implement a function for setting property's value, for each object-holder:
    PropertyObjectHolder.prototype.setValue = function (val) {
        this.innerValue = val;
    
        if (this.isOfSimpleType()) {
            if (this.validate()) {
                var self = this;
    
                if (isScope(self.rootObj)) {
                    /*--- Sometimes we should check the AngularJS scope's $$phase to
                          ensure that we aren't already in middle of an $apply or a $digest process.
                          But, since our script runs outside of AngularJS,
                          we don't have to be bothered on that issue. ---*/
                    self.rootObj.$apply(function () {
                        self.setterFn(self.rootObj, val);
                    });
                } else {
                    self.setterFn(self.rootObj, val);
                }
            }
        }
    };
    
    function isScope(obj) {
        if (obj && obj.$apply && obj.$watch && obj.$watchCollection) {
            return true;
        }
    
        return false;
    }

In the setValue function, we set the given value as the inner value of the object-holder and, if the value's type is of a simple type, we call the setter function (retrieved by $parse) with the given value.

Register for AngularJS scope changes

Watch AngularJS changes

In a previous section, we mentioned the scope's $digest function. Using that function, we notify the bound components about the scope's changes. But, how that $digest function works? How AngularJS knows which components should be notified? The answer is, that the components themselves tell the scope, which changes they want to be notified on. That's done using the scope's $watch, $watchGroup and, $watchCollection functions.

When developing Angular components (e.g. directive, etc.), we should use those functions in order to register for the relevant changes. In the $digest phase, AngularJS processes all of the scope's registered watchers.

Register for properties changes

For our case, we want to be notified about the needed properties' changes. That can be done as follows:

  1. Implement a function for registering a watcher for a property's value change:
    PropertyObjectHolder.prototype.subscribeForPropertyChange = function (propNotificationFunc) {
        if (isScope(this.rootObj) && this.isValid()) {
            this.rootObj.$watch(this.pathExp, function (newValue, oldValue) {
                propNotificationFunc();
            });
    
            return true;
        } else {
            this.pendingNotificationFunc = propNotificationFunc;
        }
    
        return false;
    };
  2. Implement a function for registering a watcher for an array's change:
    PropertyObjectHolder.prototype.subscribeForArrayChange = function (arrNotificationFunc) {
        if (isScope(this.rootObj) && this.isValid()) {
            this.rootObj.$watchCollection(this.pathExp, function (newValue, oldValue) {
                arrNotificationFunc();
            });
    
            return true;
        } else {
            this.pendingNotificationFunc = arrNotificationFunc;
        }
    
        return false;
    };

Those functions algorithm is quite easy. If the object-holder is valid (the scope and the expression have already been set), register a watch with the given function. Else, store the given function, until the object will be valid.

In the validateProperties function, we register a watch with the stored function (if it exists):

PropertyObjectHolder.prototype.validateProperties = function (rootObj, pathExpression) {
    // ...

    if (this.isArray) {
        // ...

        if (this.pendingNotificationFunc) {
            /*--- There is a notifications function that is pending for registration. ---*/
            if (this.subscribeForArrayChange(this.pendingNotificationFunc)) {
                this.pendingNotificationFunc = null;
            }
        }
    } else {
        // ...

        if (this.pendingNotificationFunc) {
            /*--- There is a notifications function that is pending for registration. ---*/
            if (this.subscribeForPropertyChange(this.pendingNotificationFunc)) {
                this.pendingNotificationFunc = null;
            }
        }
    }
};

Handle array's changes

Since we maintain a corresponding object-holder for each scope's property, we have to keep the both of the objects synchronized. For simple properties (not of array type), we don't have to do anything special, for that synchronization. But, for array properties, we need to have the same element's count in the both of the objects.

When setting a new array with greater count of elements, that synchronization is already done when reflecting the new value. But, when setting a new array with lesser count of elements, we have to remove the extra elements manually. That can be done as follows:

PropertyObjectHolder.prototype.setValue = function (val) {
    // ...

    if (this.isArray && val instanceof Array) {
        if (this.validate()) {
            var self = this;
            var realArr = self.getterFn(self.rootObj);
            if (realArr instanceof Array) {
                var realArrOldLength = realArr.length;

                if (val.length < realArrOldLength) {
                    /*--- The new array's length is smaller than the old one... ---*/
                    var lengthDiff = realArrOldLength - val.length;

                    if (isScope(self.rootObj)) {
                        self.rootObj.$apply(function () {
                            realArr.splice(val.length, lengthDiff);
                        });
                    } else {
                        realArr.splice(val.length, lengthDiff);
                    }
                }
            }
        }
    }
};

The same synchronization has to be done in the second direction too. That can be done in the array's changes watcher as follows:

PropertyObjectHolder.prototype.subscribeForArrayChange = function (arrNotificationFunc) {
    // ...

        var self = this;
        this.rootObj.$watchCollection(this.pathExp, function (newValue, oldValue) {
            if (newValue instanceof Array &&
                oldValue instanceof Array && newValue.length < oldValue.length) {
                var lengthDiff = oldValue.length - newValue.length;
                if (self.innerValue instanceof Array) {
                    self.innerValue.splice(newValue.length, lengthDiff);
                }
            }

            arrNotificationFunc();
        });

    // ...
};

Dispose unused data

When removing the unused array's elements, we have to remove their associated data too. In our implementation, each object-holder has a registered watcher in the AngularJS's scope. Each one of the AngularJS watcher registration functions ($watch, $watchGroup and, $watchCollection), returns a function that can be used for de-registering the registered watch. We can use that function for de-registering the object-holder's watch as follows:

  1. Store the de-registration function for each object holder:
    PropertyObjectHolder.prototype.subscribeForPropertyChange = function (propNotificationFunc) {
        // ...
    
            this.watchDeregistrationFunc = 
                this.rootObj.$watch(this.pathExp, function (newValue, oldValue) {
                // ...
            });
    
        // ...
    };
    
    PropertyObjectHolder.prototype.subscribeForArrayChange = function (arrNotificationFunc) {
        // ...
    
            this.watchDeregistrationFunc = 
                this.rootObj.$watchCollection(this.pathExp, function (newValue, oldValue) {
                // ...
            });
    
        // ...
    };
  2. De-register the watch function when registering a new watch and, when disposing the object-holder:
    PropertyObjectHolder.prototype.subscribeForPropertyChange = function (propNotificationFunc) {
        if (isScope(this.rootObj) && this.isValid()) {
            if (this.watchDeregistrationFunc) {
                /*--- De-register old watch. ---*/
                this.watchDeregistrationFunc();
            }
    
            // ...
        }
    
        // ...
    };
    
    PropertyObjectHolder.prototype.subscribeForArrayChange =
                    function (arrNotificationFunc) {
    
        if (isScope(this.rootObj) && this.isValid()) {
            if (this.watchDeregistrationFunc) {
                /*--- De-register old watch. ---*/
                this.watchDeregistrationFunc();
            }
    
            // ...
        }
    
        // ...
    };
    
    PropertyObjectHolder.prototype.dispose = function () {
        if (this.watchDeregistrationFunc) {
            this.watchDeregistrationFunc();
            this.watchDeregistrationFunc = null;
        }
    
        if (this.isArray) {
            /*--- Dispose PropertyObjectHolder elements of innerValue. ---*/
            if (this.innerValue instanceof Array) {
                for (var elemInx = 0; elemInx < this.innerValue.length; elemInx++) {
                    if (this.innerValue[elemInx] instanceof PropertyObjectHolder) {
                        this.innerValue[elemInx].dispose();
                    }
                }
            }
        } else {
            /*--- Dispose PropertyObjectHolder properties of innerValue. ---*/
            if (this.innerValue) {
                for (var prop in this.innerValue) {
                    if (this.innerValue[prop] instanceof PropertyObjectHolder) {
                        this.innerValue[prop].dispose();
                    }
                }
            }
        }
    };
  3. Dispose the removed object-holder elements:
    PropertyObjectHolder.prototype.applyInnerValueChanges = function () {
        if (this.isArray && this.innerValue instanceof Array) {
            if (!this.innerValueElementsShadow) {
                this.innerValueElementsShadow = [];
            }
    
            var oldLength = this.innerValueElementsShadow.length;
            var newLength = this.innerValue.length;
    
            if (newLength > oldLength) {
                /*--- New elements have been added - Add them to the shadow. ---*/
                for (var elemInx = oldLength; elemInx < newLength; elemInx++) {
                    this.innerValueElementsShadow.push(this.innerValue[elemInx]);
                }
            } else if (newLength < oldLength) {
                /*--- Elements have been removed - Dispose them. ---*/
                var removedElements =
                    this.innerValueElementsShadow.splice(newLength, oldLength - newLength);
    
                for (var elemInx = 0; elemInx < removedElements.length; elemInx++) {
                    if (removedElements[elemInx] instanceof PropertyObjectHolder) {
                        removedElements[elemInx].dispose();
                    }
                }
            }
        }
    };
    
    PropertyObjectHolder.prototype.setValue = function (val) {
    
        this.innerValue = val;
        this.applyInnerValueChanges();
    
        // ...
    };
    
    PropertyObjectHolder.prototype.subscribeForArrayChange = function (arrNotificationFunc) {
        // ...
    
            this.watchDeregistrationFunc =
                this.rootObj.$watchCollection(this.pathExp, function (newValue, oldValue) {
                if (newValue instanceof Array &&
                    oldValue instanceof Array && newValue.length < oldValue.length) {
                    var lengthDiff = oldValue.length - newValue.length;
                    if (self.innerValue instanceof Array) {
                        self.innerValue.splice(newValue.length, lengthDiff);
                        self.applyInnerValueChanges();
                    }
                }
    
                arrNotificationFunc();
            });
    
        // ...
    };

In the applyInnerValueChanges function, we maintain a shadow of the object-holder's innerValue (for array properties). If the length of the array has been increased, we add the new elements to its shadow. If the length of the array has been decreased, we dispose the removed elements.

In the setValue and the subscribeForArrayChange functions, we call to the applyInnerValueChanges function, after updating the innerValue.

WebBinding Implementation for AngularJS

Implement WebBinding functions to use AngularJS object-holders

Implement the dedicate part of the WebBinding's client

After we've created the Angular object-holders, we can use them for implementing the dedicated part of the WebBinding's generic implementation:

function WebBinding_ApplyAngularDedicateImplementation(wbObj) {

    wbObj.createObjectHolder = function () {
        var res = new PropertyObjectHolder();
        return res;
    };

    wbObj.createArrayHolder = function () {
        var res = new PropertyObjectHolder();
        res.isArray = true;
        res.setValue([]);
        return res;
    };

    wbObj.getObjectValue = function (objHolder) {
        return objHolder.getValue();
    };

    wbObj.getArrayValue = function (arrHolder) {
        return arrHolder.getValue();
    };

    wbObj.setObjectValue = function (objHolder, val) {
        objHolder.setValue(val);
    };

    wbObj.setArrayValue = function (arrHolder, val) {
        arrHolder.setValue(val);
    };

    wbObj.registerForPropertyChanges = function (objHolder, propNotificationFunc) {
        objHolder.subscribeForPropertyChange(propNotificationFunc);
    };

    wbObj.registerForArrayChanges = function (arrHolder, arrNotificationFunc) {
        arrHolder.subscribeForArrayChange(arrNotificationFunc);
    };
}

In the createObjectHolder and the createArrayHolder functions, we create an instance of our Angular object-holder. In the other functions (getObjectValue, getArrayValue, setObjectValue, setArrayValue, registerForPropertyChanges and, registerForArrayChanges), we call the appropriate object-holder's functions.

Integrate with AngularJS bootstrap

Since our object-holders depend on wrapping an Angular scope object, we need the appropriate scope, when constructing the client model. But, when we set our WebBinding client model, the Angular model hasn't been loaded yet and, no scope objects have been created. While the WebBinding client script is executed immediately when the page processes the script, AngularJS is initialized only upon the DOMContentLoaded event. Therefore, in order to construct our client model after AngularJS has been loaded (and, our scopes have been created), we should run the construction script after the DOMContentLoaded event has been raised.

For constructing a binding's client model, we call the addBindingMapping function (The script is generated automatically when calling the WebBinder method). The original declaration of the addBindingMapping function, is:

this.addBindingMapping = function (_bindingId_, rootObj, bindingMappingObj) {
    // ...
};

This function takes 3 parameters:

  1. _bindingId_: The identifier of the binding-mapping. (There can be some binding-mappings for a single page.)
  2. rootObj: The root object on the client side to apply the binding on.
  3. bindingMappingObj: An object that describes the properties that should be bound.

In our case, the root object (the 2nd parameter) is the AngularJS's scope. Since when we call the addBindingMapping function the scope isn't exist yet, instead of sending the scope itself, we send a function that gets the scope.

In order to construct the client model after the AngularJS's scope has been created, instead of creating the client model in the function's body, we store the parameters for a later use. That's done by overriding the addBindingMapping function as follows:

function BindingMappingRegistration(bindingId, scopeGetter, bindingMappingObj) {
    this.bindingId = bindingId;
    this.scopeGetter = scopeGetter;
    this.bindingMappingObj = bindingMappingObj;
}

var bindingMappingsRegistrations = [];

wbObj.angImp_orgAddBindingMapping = wbObj.addBindingMapping;

wbObj.addBindingMapping = function (bindingId, scopeGetter, bindingMappingObj) {
    var reg = new BindingMappingRegistration(bindingId, scopeGetter, bindingMappingObj);
    bindingMappingsRegistrations.push(reg);
};

In the applyBindings function, we build client object (call the original addBindingMapping function) using the stored data. Since the applyBindings function is also called before the DOMContentLoaded event, we wait with that implementation until the event is fired. The overridden function is:

wbObj.angImp_orgApplyBindings = wbObj.applyBindings;

wbObj.applyBindings = function () {
    isApplyBindingsCalled = true;

    if (!isDOMContentLoaded) {
        /*--- The 'DOMContentLoaded' event hasn't been fired (and, AngularJS hasn't been loaded)... ---*/
        return;
    }

    var bindingMappingsRegistrationsCount = bindingMappingsRegistrations.length;

    for (var regInx = 0; regInx < bindingMappingsRegistrationsCount; regInx++) {
        var reg = bindingMappingsRegistrations[regInx];

        var scope = reg.scopeGetter();
        if (scope) {
            var rootObjWrapper = new RootObjectWrapper(scope);
            rootObjectWrappers.push(rootObjWrapper);

            wbObj.angImp_orgAddBindingMapping(reg.bindingId,
                rootObjWrapper.wrapperRootObj, reg.bindingMappingObj);
        }
    }

    /*--- Clear binding-mappings registrations. ---*/
    bindingMappingsRegistrations.splice(0, bindingMappingsRegistrationsCount);

    wbObj.angImp_orgApplyBindings();
};

The beginServerChangesRequests function is called for starting binding's communication. Since that function is also called before the DOMContentLoaded event, we wait for the DOMContentLoaded event for that function too:

wbObj.angImp_orgBeginServerChangesRequests = wbObj.beginServerChangesRequests;

wbObj.beginServerChangesRequests = function () {
    isBeginServerChangesRequestsCalled = true;

    if (!isDOMContentLoaded) {
        /*--- The 'DOMContentLoaded' event hasn't been fired
        (and, AngularJS hasn't been loaded)... ---*/
        return;
    }

    wbObj.angImp_orgBeginServerChangesRequests();
};

Finally, we add a DOMContentLoaded event listener that calls the suspended functions:

function onDOMContentLoaded() {
    isDOMContentLoaded = true;

    if (isBeginServerChangesRequestsCalled) {
        /*--- The 'beginServerChangesRequests' function
        was called before the 'DOMContentLoaded' event... ---*/
        wbObj.beginServerChangesRequests();
    }

    if (isApplyBindingsCalled) {
        /*--- The 'applyBindings' function was called before the 'DOMContentLoaded' event... ---*/
        wbObj.applyBindings();
    }
}

window.addEventListener('DOMContentLoaded', onDOMContentLoaded);
Create bound objects

Until now, we've overridden the whole of the WebBinding client's public functions except one - the createBoundObjectForPropertyPath function. The purpose of that function is for creating client-side objects that are bound with the WebBinding mechanism. When I developed this function, I worked with the Knockout library and, since the Knockout's model is constructed using an "object-holder" for each observed property, I used the same model also for the WebBinding's model. Using AngularJS, since the WebBinding's model isn't the same model as the AngularJS's model, we have to override this function too.

The original createBoundObjectForPropertyPath function has the following declaration:

this.createBoundObjectForPropertyPath = function (rootObj, _propPath_) {
    // ...
};

This function takes 2 parameters:

  1. rootObj: The root object on the client side that the binding is applied on.
  2. _propPath_: A string that represents the path from the root object to the actual property.

In our case (where the WebBinding's model isn't the same model as the AngularJS's model), the provided root object (the 1st parameter) isn't the WebBinding's root object (it's the Angular's scope). So, in order to handle that issue, we implement the createBoundObjectForPropertyPath function, to retrieve the WebBinding's property's object-holder (according to the provided AngularJS's scope and property-path) and, get the property's value using the AngularJS's $parse service:

RootObjectWrapper.prototype.retrieveBoundObjectForPropertyPath = function (_propPath_) {
    var resObj = this.wrapperRootObj;

    var currPropPath = "";
    var propPathExt = _propPath_;

    while (propPathExt.length > 0) {
        var currPropName;

        var firstDotIndex = propPathExt.indexOf(".");
        var firstBracketIndex = propPathExt.indexOf("[");

        var isArrayElement = false;

        if (firstBracketIndex >= 0 && (firstDotIndex < 0 || firstBracketIndex < firstDotIndex)) {
            /*--- There is a bracket before the dot. - This is an array property. ---*/
            if (firstBracketIndex == 0) {
                /*--- This is an array's element... ---*/
                var firstCloseBracketIndex = propPathExt.indexOf("]");
                currPropName = propPathExt.substr(1, firstCloseBracketIndex - firstBracketIndex - 1);

                /*--- If there is a dot directly after the closing bracket, we should skip it. ---*/
                propPathExt = propPathExt.substr(firstCloseBracketIndex +
                    (((firstDotIndex - firstCloseBracketIndex) == 1) ? 2 : 1));

                isArrayElement = true;
            } else {
                currPropName = propPathExt.substr(0, firstBracketIndex);
                propPathExt = propPathExt.substr(firstBracketIndex);
            }
        } else {
            if (firstDotIndex >= 0) {
                currPropName = propPathExt.substr(0, firstDotIndex);
                propPathExt = propPathExt.substr(firstDotIndex + 1);
            } else {
                currPropName = propPathExt;
                propPathExt = "";
            }
        }

        if (isArrayElement) {
            currPropPath += '[' + currPropName + ']';

            /*--- For and array element the property's name is an element's index... ---*/
            currPropName = parseInt(currPropName);
        } else {
            if (currPropPath.length > 0) {
                currPropPath += '.';
            }

            currPropPath += currPropName;
        }

        if (!resObj[currPropName] || !(resObj[currPropName] instanceof PropertyObjectHolder)) {
            resObj[currPropName] =
                wbObj.angImp_orgCreateBoundObjectForPropertyPath(this.wrapperRootObj, currPropPath);

            /*--- The WebBinding's property-path's syntax is same as the AngularJS expression's syntax.
                    So, we can use it as the property-expression too. ---*/
            resObj[currPropName].validateProperties(this.orgRootObj, currPropPath);
        }

        resObj = resObj[currPropName].getValue();
    }

    return resObj;
};

wbObj.angImp_orgCreateBoundObjectForPropertyPath = wbObj.createBoundObjectForPropertyPath;

wbObj.createBoundObjectForPropertyPath = function (rootObj, _propPath_) {
    var res = null;

    for (var wrapperInx = 0; wrapperInx < rootObjectWrappers.length; wrapperInx++) {
        var objWrapper = rootObjectWrappers[wrapperInx];
        if (objWrapper instanceof RootObjectWrapper && objWrapper.orgRootObj === rootObj) {
            objWrapper.retrieveBoundObjectForPropertyPath(_propPath_);
        }
    }

    if (rootObj) {
        var getter = angParser(_propPath_);
        res = getter(rootObj);
    }

    return res;
};

In the retrieveBoundObjectForPropertyPath function, we ensure that there is a valid bound path from the webBinding's root object to the property's object-holder (and, create it if it doesn't exist).

In the createBoundObjectForPropertyPath function, we find the appropriate RootObjectWrapper according to the provided scope and, use it for creating the bound path.

Implement a class for defining AngularJS WebBinding

The final step, after implementing our AngularJS WebBinding script, is to inject it to our page. That can be done in the same manner as we injected the Knockout WebBinding script, by implementing a dedicate class (that derives from BinderDefinitions), for the AngularJS's binding definitions:

public class AngularBinderDefinitions : BinderDefinitions
{
    private const string _originalApplyDedicateImplementationFunctionName = 
                "WebBinding_ApplyAngularDedicateImplementation";

    public AngularBinderDefinitions()
    {
        ApplyDedicateImplementationFunctionName = "WebBinding_ApplyAngularDedicateImplementation";
    }

    #region Properties
    public string ApplyDedicateImplementationFunctionName { get; set; }
    #endregion

    #region BinderDefinitions implementation

    protected override string GetApplyDedicateImplementationScript()
    {
        StringBuilder sb = new StringBuilder();

        sb.AppendLine(GetDedicateImplementationScript());
        sb.AppendFormat("{0}({1});", ApplyDedicateImplementationFunctionName, BinderClientObjectName);

        return sb.ToString();
    }

    #endregion

    private string GetDedicateImplementationScript()
    {
        string res = string.Empty;

        Uri resUri = new Uri
        ("/WebBinding.Angular;component/Scripts/AngularDedicateImplementation.js", UriKind.Relative);

        lock (ResourcesLocker)
        {
            StreamResourceInfo resInfo = Application.GetResourceStream(resUri);
            if (resInfo != null)
            {
                using (StreamReader sr = new StreamReader(resInfo.Stream))
                {
                    res = sr.ReadToEnd();
                }
            }
        }

        res = Regex.Replace
        (res, _originalApplyDedicateImplementationFunctionName, ApplyDedicateImplementationFunctionName);

        if (MinimizeClientScript)
        {
            // Remove comments.
            res = Regex.Replace(res, "/\\*-{3}([\\r\\n]|.)*?-{3}\\*/", string.Empty);

            // Remove lines' spaces
            res = Regex.Replace(res, "[\\r\\n][\\r\\n \\t]*", string.Empty);

            // Remove additional spaces
            res = Regex.Replace(res, " ?([=\\+\\{\\},\\(\\)!\\?:\\>\\<\\|&\\]\\[-]) ?", "$1");
        }

        return res;
    }
}

How to Use It

Apply WebBinding on the Page

For demonstrating the use of the WebBinding library using the AngularJS library, we use the same examples we used for demonstrating the WebBinding library using the Knockout library. Since the only changes are in the client side (for writing it using AngularJS instead of Knockout), we skip the discussion on the server's code and, concentrate on the specific changes for the AngularJS library.

The first step for applying WebBinding on our page is to get the appropriate scopes (the scopes that we want to bind to .NET objects). That can be done by setting a variable with the controller's scope, in the controller's constructor:

// Unique view-model

var uniqueVmScope;

function uniqueVmController($scope) {
    uniqueVmScope = $scope;
}

// Shared view-model

var sharedVmScope;

function sharedVmController($scope) {
    sharedVmScope = $scope;
}

In our examples, we have 2 scopes (handled by 2 controllers). One for binding to a .NET view-model that is unique to the specific page and, one for binding to a .NET view-model that is shared between the whole of the pages. For getting these scopes, we add other 2 functions:

function getUniqueVmScope() {
    return uniqueVmScope;
}

function getSharedVmScope() {
    return sharedVmScope;
}

When the controllers are constructed (in the Angular initialization), we set the appropriate scopes variables. When the WebBinding client is constructed (on the DOMContentLoaded event, after the Angular initialization), the scopes variables have already been set and the functions return the appropriate scopes.

After we have the functions for getting our scopes, we can use them for creating a BinderDefinitions object that holds the relevant binding-mappings:

@{
    BinderDefinitions bd = new AngularBinderDefinitions();
    bd.AddBinding("getUniqueVmScope", ViewData.Model);
    bd.AddBinding("getSharedVmScope", ExampleContext.Instance.CommonBindPropertiesExampleViewModel);
}

For applying WebBinding on our page using the created BinderDefinitions, we call the WebBinder extension method:

@Html.WebBinder(bd)

Present the Examples

Apply the Examples Controller

For presenting our examples, we add an article tag and apply the controller of the shared view-model to it:

<article ng-controller="sharedVmController">
</article>

Example 1: Shared view-model vs. unique view-model

In the first example, we bind to 2 instances of a view-model that contains a web-bound Text property. One of the instances is shared with the whole of the pages and, the other one is unique for the specific page. For presenting our example, we add 2 input tags that presents the bound Text property (one for the shared view-model and, one for the unique view-model):

<section>
    <h3>Example 1: Shared view-model vs. unique view-model</h3>
    <p class="exampleDescription">In this example, we compare between shared 
       (with the other pages) view-model and, unique (to this page) view-model.
        We can see how the change on the shared view-model is reflected to the other pages 
       (open this page in some tabs/windows),
        while the change on the unique view-model stays unique to that page.</p>
    <h4>Shared view-model</h4>
    <p>
        Text: <input type="text" ng-model="text"/> -
        Entered value: <span style="color :blue">{{text}}</span>
    </p>
    <h4>Unique view-model</h4>
    <p ng-controller="uniqueVmController">
        Text: <input type="text" ng-model="text"/> -
        Entered value: <span style="color :blue">{{text}}</span>
    </p>
</section>

The server's code and the result are same as in the original example.

Example 2: 2 Dimensional Collection

In the second example, we present a web-bound 2 dimensional collection of integers. The collection's values are updated randomly by the server (and reflected to the whole of the clients). For presenting our example, we add 2 input tags for setting the collection's dimensions and, a table for presenting the collection:

<section>
    <h3>Example 2: 2 dimensional collection</h3>
    <p class="exampleDescription">In this example,
    we change the columns' number and the rows' number of a 2D collection.
        In addition to that, the cells' values are changed randomly by the server.
        We can see how the values are synchronized with the other pages.</p>
    <p>
        Rows count: <input type="text" ng-model="numbersBoardRowsCount"/> -
        Entered value: <span style="color :blue">{{numbersBoardRowsCount}}</span>
        <br />
        Columns count: <input type="text" ng-model="numbersBoardColumnsCount"/> -
        Entered value: <span style="color :blue">{{numbersBoardColumnsCount}}</span>
        <br />
    </p>

    <table style="background:lightgray;border:gray 1px solid;width:100%">
        <tbody>
            <tr ng-repeat="row in numbersBoard">
                <!-- Since duplicates in a repeater
                are not allowed (https://docs.angularjs.org/error/ngRepeat/dupes),
                        we use the 'track by' expression. -->
                <td style="background:lightyellow;border:goldenrod 1px solid"

                ng-repeat="col in row track by $index">
                    <span style="color :blue">{{col}}</span>
                </td>
            </tr>
        </tbody>
    </table>
</section>

The server's code and the result are same as in the original example.

Example 3: String as a Collection

In the third example, we present a web-bound string in two ways: as a string and, as a collection of characters. For presenting our example, we add an input tag for presenting our string as a string and, a table for presenting our string as a characters' collection:

<section>
    <h3>Example 3: String as a collection</h3>
    <p class="exampleDescription">In this example, we show a string as a collection of characters.</p>
    <h4>The string</h4>
    <p>
        StringEntry: <input type="text" ng-model="StringEntry"/> -
        Entered value: <span style="color :blue">{{StringEntry}}</span>
    </p>
    <h4>The string's characters</h4>
    <table style="background:lightgray;border:gray 1px solid;width:100%">
        <tbody>
            <tr>
                <td style="background:lightyellow;border:goldenrod 1px solid"

                    ng-repeat="c in StringEntryCharacters track by $index">
                    <span style="color :blue">{{c}}</span>
                </td>
            </tr>
        </tbody>
    </table>
</section>

The server's code and the result are same as in the original example.

Example 4: Change Collections from the Client Side

In the fourth example, we present a web-bound collections of a more complex (than simple types like string, int, etc...) type. We can add or remove items from these collections in the client side (and, see how the changes are reflected to the other clients).

In order to create new collection's elements in the client side, in a way that their changes will be reflected to the server, we have to create objects that have registrations for their properties' changes. For that purpose, we expose a function that creates a web-bound object:

@{
    BinderDefinitions bd = new AngularBinderDefinitions();
    bd.CreateBoundObjectFunctionName = "createWebBoundObject";

    // ...
}

For enabling adding or removing collection's elements in the client side, we add appropriate functions to our scope:

function sharedVmController($scope) {
    sharedVmScope = $scope;

    // Actions for example 4.

    $scope.removePerson = function (person) {
        var peopleArr = this.people;

        var foundIndex = -1;
        for (var personInx = 0; personInx < peopleArr.length && foundIndex < 0; personInx++) {
            if (peopleArr[personInx] == person) {
                foundIndex = personInx;
            }
        }

        if (foundIndex >= 0) {
            peopleArr.splice(foundIndex, 1);
        }
    };

    $scope.removeChild = function (child) {
        var peopleArr = this.people;

        var foundIndex = -1;
        for (var personInx = 0; personInx < peopleArr.length && foundIndex < 0; personInx++) {
            var childrenArr = peopleArr[personInx].children;

            for (var childInx = 0; childInx < childrenArr.length && foundIndex < 0; childInx++) {
                if (childrenArr[childInx] == child) {
                    foundIndex = childInx;
                }
            }

            if (foundIndex >= 0) {
                childrenArr.splice(foundIndex, 1);
            }
        }
    };

    $scope.addPerson = function () {
        var peopleArr = $scope.people;
        var newIndex = peopleArr.length;
        var propPath = "people[" + newIndex + "]";

        // After the call to 'createWebBoundObject'
        // the array element has already been created in the scope.
        var person = createWebBoundObject($scope, propPath);
        person.name.firstName = "Added_First" + (newIndex + 1);
        person.name.lastName = "Added_Last" + (newIndex + 1);
        person.age = 40 + newIndex;
    };

    $scope.addChild = function (person) {
        // Find person's index.
        var peopleArr = $scope.people;
        var foundIndex = -1;

        for (var personInx = 0; personInx
            < peopleArr.length && foundIndex < 0; personInx++) {
            if (peopleArr[personInx] == person) {
                foundIndex = personInx;
            }
        }

        // Add child to the found person.
        if (foundIndex >= 0) {
            var childrenArr = peopleArr[foundIndex].children;
            var newIndex = childrenArr.length;
            var propPath = "people[" + foundIndex + "].children[" + newIndex + "]";

            // After the call to 'createWebBoundObject'
            // the array element has already been created in the scope.
            var child = createWebBoundObject($scope, propPath);
            child.name.firstName = "Added_First" + (foundIndex + 1) + "_" + (newIndex + 1);
            child.name.lastName = "Added_Last" + (foundIndex + 1) + "_" + (newIndex + 1);
            child.age = 20 + newIndex;
        }
    };
}

For presenting our example, we add a list for presenting our collection and, buttons for applying the appropriate actions:

<section>
    <h3>Example 4: Change collections from the client side</h3>
    <p class="exampleDescription">In this example, we add and remove collecion's elements 
        (from the client side).
        We can see how the changes are reflected to the other pages.</p>
    <h4>People collection</h4>
    <ol>
        <li ng-repeat="p in people">Name:
        <span style="color :blue">{{p.name.firstName}}</span>
            <span style="color :brown">, </span>
            <span style="color :blue">{{p.name.lastName}}</span>
            Age: <span style="color :blue">{{p.age}}</span>
            <button ng-click="$parent.removePerson(p)">Remove</button>
            <br />
            Children:
            <ol>
                <li ng-repeat="c in p.children">
                    Name: <span style="color :blue">{{c.name.firstName}}</span>
                    <span style="color :brown">, </span>
                    <span style="color :blue">{{c.name.lastName}}</span>
                    Age: <span style="color :blue">{{c.age}}</span>
                    <button ng-click="$parent.$parent.removeChild(c)">Remove</button>
                </li>
            </ol>
            <button ng-click="$parent.addChild(p)">Add child</button>
        </li>
    </ol>
    <button ng-click="addPerson()">Add person</button>
</section>

The server's code and the result are the same as in the original example.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Shmuel Zang
Software Developer
Israel Israel
No Biography provided

Comments and Discussions

 
QuestionVery nice tuto Pin
MathsInBinaries20-Mar-18 6:09
memberMathsInBinaries20-Mar-18 6:09 
AnswerRe: Very nice tuto Pin
Shmuel Zang15-Jun-18 2:49
memberShmuel Zang15-Jun-18 2:49 
GeneralMy vote of 5 Pin
Humayun Kabir Mamun26-Feb-15 18:56
memberHumayun Kabir Mamun26-Feb-15 18:56 
GeneralRe: My vote of 5 Pin
Shmuel Zang27-Feb-15 0:47
memberShmuel Zang27-Feb-15 0:47 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

Article
Posted 26 Feb 2015

Stats

19.1K views
337 downloads
21 bookmarked