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:
- 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 = {};
}
- 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:
- 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();
};
- 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()) {
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:
- 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();
};
- 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)) {
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:
- 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;
};
- 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) {
if (this.subscribeForArrayChange(this.pendingNotificationFunc)) {
this.pendingNotificationFunc = null;
}
}
} else {
if (this.pendingNotificationFunc) {
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) {
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:
- 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) {
});
};
- 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) {
this.watchDeregistrationFunc();
}
}
};
PropertyObjectHolder.prototype.subscribeForArrayChange =
function (arrNotificationFunc) {
if (isScope(this.rootObj) && this.isValid()) {
if (this.watchDeregistrationFunc) {
this.watchDeregistrationFunc();
}
}
};
PropertyObjectHolder.prototype.dispose = function () {
if (this.watchDeregistrationFunc) {
this.watchDeregistrationFunc();
this.watchDeregistrationFunc = null;
}
if (this.isArray) {
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 {
if (this.innerValue) {
for (var prop in this.innerValue) {
if (this.innerValue[prop] instanceof PropertyObjectHolder) {
this.innerValue[prop].dispose();
}
}
}
}
};
- 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) {
for (var elemInx = oldLength; elemInx < newLength; elemInx++) {
this.innerValueElementsShadow.push(this.innerValue[elemInx]);
}
} else if (newLength < oldLength) {
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:
_bindingId_
: The identifier of the binding-mapping. (There can be some binding-mappings for a single page.) rootObj
: The root object on the client side to apply the binding on. 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) {
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);
}
}
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) {
return;
}
wbObj.angImp_orgBeginServerChangesRequests();
};
Finally, we add a DOMContentLoaded
event listener that calls the suspended functions:
function onDOMContentLoaded() {
isDOMContentLoaded = true;
if (isBeginServerChangesRequestsCalled) {
wbObj.beginServerChangesRequests();
}
if (isApplyBindingsCalled) {
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:
rootObj
: The root object on the client side that the binding is applied on. _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)) {
if (firstBracketIndex == 0) {
var firstCloseBracketIndex = propPathExt.indexOf("]");
currPropName = propPathExt.substr(1, firstCloseBracketIndex - firstBracketIndex - 1);
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 + ']';
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);
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)
{
res = Regex.Replace(res, "/\\*-{3}([\\r\\n]|.)*?-{3}\\*/", string.Empty);
res = Regex.Replace(res, "[\\r\\n][\\r\\n \\t]*", string.Empty);
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:
var uniqueVmScope;
function uniqueVmController($scope) {
uniqueVmScope = $scope;
}
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">
<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;
$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 + "]";
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) {
var peopleArr = $scope.people;
var foundIndex = -1;
for (var personInx = 0; personInx
< peopleArr.length && foundIndex < 0; personInx++) {
if (peopleArr[personInx] == person) {
foundIndex = personInx;
}
}
if (foundIndex >= 0) {
var childrenArr = peopleArr[foundIndex].children;
var newIndex = childrenArr.length;
var propPath = "people[" + foundIndex + "].children[" + newIndex + "]";
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.