I am trying to create an AngularJS factory that maintains a collection of resources automatically by retrieving the initial items from the API and then listening for socket updates to keep the collection current.
angular.module("myApp").factory("myRESTFactory", function (Resource, Socket, ErrorHandler, Confirm, $mdToast, $q, $rootScope) {
var Factory = {};
// Resource is the ngResource that fetches from the API
// Factory.collection is where we'll store the items
Factory.collection = Resource.query();
// manually add something to the collection
Factory.push = function(item) {
Factory.collection.push(item);
};
// search the collection for matching objects
Factory.find = function(opts) {
return $q(function(resolve, reject) {
Factory.collection.$promise.then(function(collection){
resolve(_.where(Factory.collection, opts || {}));
});
});
};
// search the collection for a matching object
Factory.findOne = function(opts) {
return $q(function(resolve, reject) {
Factory.collection.$promise.then(function(collection){
var item = _.findWhere(collection, opts || {});
idx = _.findIndex(Factory.collection, function(u) {
return u._id === item._id;
});
resolve(Factory.collection[idx]);
});
});
};
// create a new item; save to API & collection
Factory.create = function(opts) {
return $q(function(resolve, reject) {
Factory.collection.$promise.then(function(collection){
Resource.save(opts).$promise.then(function(item){
Factory.collection.push(item);
resolve(item);
});
});
});
};
Factory.update = function(item) {
return $q(function(resolve, reject) {
Factory.collection.$promise.then(function(collection){
Resource.update({_id: item._id}, item).$promise.then(function(item) {
var idx = _.findIndex(collection, function(u) {
return u._id === item._id;
});
Factory.collection[idx] = item;
resolve(item);
});
});
});
};
Factory.delete = function(item) {
return $q(function(resolve, reject) {
Factory.collection.$promise.then(function(collection){
Resource.delete({_id: item._id}, item).$promise.then(function(item) {
var idx = _.findIndex(collection, function(u) {
return u._id === item._id;
});
Factory.collection.splice(idx, 1);
resolve(item);
});
});
});
};
// new items received from the wire
Socket.on('new', function(item){
idx = _.findIndex(Factory.collection, function(u) {
return u._id === item._id;
});
if(idx===-1) Factory.collection.push(item);
// this doesn't help
$rootScope.$apply();
});
Socket.on('update', function(item) {
idx = _.findIndex(Factory.collection, function(u) {
return u._id === item._id;
});
Factory.collection[idx] = item;
// this doesn't help
$rootScope.$apply();
});
Socket.on('delete', function(item) {
idx = _.findIndex(Factory.collection, function(u) {
return u._id === item._id;
});
if(idx!==-1) Factory.collection.splice(idx, 1);
});
return Factory;
});
My backend is solid and the socket messages come through correctly. However, the controllers don't respond to updates to the collection if any of the Factory methods are used.
i.e.
This works (responds to socket updates to the collection):
$scope.users = User.collection;
This does not work (it loads the user initially but is not aware of updates to the collection):
User.findOne({ _id: $routeParams.user_id }).then(function(user){
$scope.user = user;
});
How can I get my controllers to respond to update to changes to the collection?
Update:
I was able to implement a workaround in the controller by changing this:
if($routeParams.user_id) {
User.findOne({ _id: $routeParams.user_id }).then(function(user){
$scope.user = user;
});
}
To this:
$scope.$watchCollection('users', function() {
if($routeParams.user_id) {
User.findOne({ _id: $routeParams.user_id }).then(function(user){
$scope.user = user;
});
}
});
However, nobody likes workarounds, especially when it involves redundant code in your controllers. I am adding a bounty to the question for the person who can solve this inside the Factory.
collection
property on Factory
, keep it as a local variable. getter/setter
on the Factory that proxies to and from the local variable. find
methods.Something like this:
// internal variable
var collection = Resource.query();
// exposed 'proxy' object
Object.defineProperty(Factory, 'collection', {
get: function () {
return collection;
},
set: function (item) {
// If we got a finite Integer.
if (_.isFinite(item)) {
collection.splice(item, 1);
}
// Check if the given item is already in the collection.
var idx = _.findIndex(Factory.collection, function(u) {
return u._id === item._id;
});
if (idx) {
// Update the item in the collection.
collection[idx] = item;
} else {
// Push the new item to the collection.
collection.push(item);
}
// Trigger the $digest cycle as a last step after modifying the collection.
// Can safely be moved to Socket listeners so as to not trigger unnecessary $digests from an angular function.
$rootScope.$digest();
}
});
/**
* Change all calls from 'Factory.collection.push(item)' to
* 'Factory.collection = item;'
*
* Change all calls from 'Factory.collection[idx] = item' to
* 'Factory.collection = item;'
*
* Change all calls from 'Factory.collection.splice(idx, 1) to
* 'Factory.collection = idx;'
*
*/
Now, seeing as how the non angular parties modify your collection (namely Sockets in this case), you will need to trigger a $digest
cycle to reflect the new state of the collection.
If you are only ever interested in keeping the collection in sync in a single $scope
(or multiple ones, but not cross-scope) I would attach said $scope
's to the factory, and run the $digest
there instead of $rootScope
. That will save you a little bit of performance down the line.
here's a jsbin showcasing how the usage of an Object.getter
will keep your collection in sync and allow you to find items recently added to the collection.
I've opted for setTimeout
in the jsbin so as to not trigger automatic $digests
through the usage of $interval
.
Obviously the jsbin is very barebones; There's no promises being shuffled around, no socket connections. I only wanted to showcase how you can keep things in sync.
I will admit that Factory.collection = value
looks whack, but you could hide that away with the help of wrapping functions to make it more pretty / read better.