3

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.

Takahiko Kawasaki
  • 18,118
  • 9
  • 62
  • 105
mz3
  • 1,314
  • 11
  • 27

2 Answers2

1
  • Don't expose the collection property on Factory, keep it as a local variable.
  • Create a new exposed getter/setter on the Factory that proxies to and from the local variable.
  • Use the getter/setter Object internally in your 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.

  • This is very interesting and (I think) close. I'm playing with the jsbin but haven't been able yet to accomplish something like `$scope.boundCollection = MyFactory.find({id: 1});` which is the use case for this factory. Since the item with `{id: 1}` doesn't exist yet, it renders nothing, even when the item is added later to the collection. – mz3 Jul 15 '15 at 15:11
  • Hm. I must've missed something here; Are you saying that the use case is to find a single entity in the collection, that has not yet been added to said collection? If so, I think I have a solution to the problem. –  Jul 15 '15 at 15:22
  • More generally, the goal is to query the collection from the controller and have the object returned be 'live'. i.e. If it is updated in the collection, the changes are automatically reflected in the view (a $digest is triggered). For example, `$scope.user = MyFactory.find({id: 1});` Then in the view, `

    {{user.name}}

    ` The goal being that the `

    ` is automatically updated if that user's name is changed via socket or something else.

    – mz3 Jul 15 '15 at 15:26
  • Ah, I see! That makes it a wee bit easier to understand, thanks. Expect an edit to my answer soon™. –  Jul 15 '15 at 15:35
  • "soon™" - as in, as soon as I figure out how that would be feasible. Quite the amount of boilerplate that needs to be written. For now, I would say go with a `$watch` and/or `$broadcast` when the Socket hits. –  Jul 15 '15 at 15:54
  • Cool. There's really no hurry. Thanks so much for taking this one on, I think lots of people will benefit from a comprehensive example of a client site collection like this. – mz3 Jul 15 '15 at 15:55
  • I came up with an absolutely fantastic solution I will post shortly. Thank you so much for getting me started. – mz3 Jul 16 '15 at 16:09
  • I shared my solution and marked it as the correct answer. However I'm awarding you the bounty for all the help and effort you put in, and for getting me going in the right direction. Thanks again. – mz3 Jul 16 '15 at 16:43
1

The solution is for the factory methods to return an empty object/array to be populated later (similar to the way ngResource works). Then attach the socket listeners to both those return objects/arrays and the main Factory.collection array.

angular.module("myApp").factory("myRESTFactory",
  function (Resource, Socket, ErrorHandler, Confirm, $mdToast, $q) {

  var Factory = {};

  // Resource is the ngResource that fetches from the API
  // Factory.collection is where we'll store the items
  Factory.collection = Resource.query();

  // This function attaches socket listeners to given array
  // or object and automatically updates it based on updates
  // from the websocket
  var socketify = function(thing, opts){

    // if attaching to array
    // i.e. myRESTFactory.find({name: "John"})
    // was used, returning an array
    if(angular.isArray(thing)) {

      Socket.on('new', function(item){

        // push the object to the array only if it
        // matches the query object
        var matches = $filter('find')([item], opts);

        if(matches.length){
          var idx = _.findIndex(thing, function(u) {
            return u._id === item._id;
          });
          if(idx===-1) thing.push(item);
        }
      });

      Socket.on('update', function(item) {
        var idx = _.findIndex(thing, function(u) {
          return u._id === item._id;
        });

        var matches = $filter('find')([item], opts);

        // if the object matches the query obj,
        if(matches.length){

          // and is already in the array
          if(idx > -1){

            // then update it
            thing[idx] = item;

          // otherwise
          } else {

            // add it to the array
            thing.push(item);
          }

        // if the object doesn't match the query
        // object anymore,
        } else {

          // and is currently in the array
          if(idx > -1){

            // then splice it out
            thing.splice(idx, 1);
          }
        }
      });

      Socket.on('delete', function(item) {

        ...

      });

    // if attaching to object
    // i.e. myRESTFactory.findOne({name: "John"})
    // was used, returning an object
    } else if (angular.isObject(thing)) {

      Socket.on('update', function(item) {
        ...
      });

      Socket.on('delete', function(item) {
        ...
      });

    }

    // attach the socket listeners to the factory
    // collection so it is automatically maintained
    // by updates from socket.io
    socketify(Factory.collection);

    // return an array of results that match
    // the query object, opts
    Factory.find = function(opts) {

      // an empty array to hold matching results
      var results = [];

      // once the API responds,
      Factory.collection.$promise.then(function(){

        // see which items match
        var matches = $filter('find')(Factory.collection, opts);

        // and add them to the results array
        for(var i = matches.length - 1; i >= 0; i--) {
          results.push(matches[i]);
        }
      });

      // attach socket listeners to the results
      // array so that it is automatically maintained
      socketify(results, opts);

      // return results now. initially it is empty, but
      // it will be populated with the matches once
      // the api responds, as well as pushed, spliced,
      // and updated since we socketified it
      return results;
    };

    Factory.findOne = function(opts) {
      var result = {};

      Factory.collection.$promise.then(function(){
        result = _.extend(result, $filter('findOne')(Factory.collection, opts));
      });

      socketify(result);

      return result;
    };

    ...

    return Factory;
  };

The reason this is so so great is that your controllers can be ridiculously simple yet powerful at the same time. For example,

$scope.users = User.find();

This returns an array of ALL users that you can use in your view; in an ng-repeat or something else. It will automatically be updated/spliced/pushed by updates from the socket and you don't need to do anything extra to get that. But wait, there's more.

$scope.users = User.find({status: "active"});

This will return an array of all active users. That array will also automatically be managed and filtered by our socketify function. So if a user is updated from "active" to "inactive", he is automatically spliced from the array. The inverse is also true; a user that gets updated from "inactive" to "active" is automatically added to the array.

The same is true for the other methods as well.

$scope.user = User.findOne({firstname: "Jon"});

If Jon's email changes, the object in the controller is updated. If his firstname changes to "Jonathan", $scope.user becomes an empty object. Better UX would be to do soft-delete or just mark the user deleted somehow, but that can be added later.

No $watch, $watchCollection, $digest, $broadcast, required--it just works.

mz3
  • 1,314
  • 11
  • 27
  • Quite the nifty solution there! The one thing I would add though would be cleanup of the `socket.on` listeners once the collection and/or `$scope` is destroyed. Happy to be of some assistance :) –  Jul 16 '15 at 21:03
  • Thanks! I was thinking that too, but given the `$scope` isn't available from the factory, how would you go about detecting its destruction? – mz3 Jul 16 '15 at 21:07
  • I would create another utility method on the factory. `Factory.attach` or something of the sort, and store a reference to the scope inside the factory. Listen for the `$destroy` event, cleanup, and remove the reference. –  Jul 16 '15 at 21:09
  • That's a good idea. I will give it some more thought to see if its possible without extra controller code as that wouldn't be in line with my overzealousness for light controllers. :) – mz3 Jul 16 '15 at 21:12