Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Loading…

feat(promiseTracker): add promiseTracker service #448

Closed
wants to merge 1 commit into from

6 participants

@ajoslin

from http://github.com/ajoslin/angular-promise-tracker

angular-promise-tracker

Demo

So you're building your angular app. And you want a loading spinner.

You've tried the normal solution (or maybe you haven't), and it has problems. It presents a loading spinner on every request!

But you don't want the same global loading spinner whenever any request happens anywhere. That just won't work!

Instead, you want different indicators while different types of request are loading. You want one spinner while you're fetching data having to do with a user's pizza order, one while fetching user's profile data, and maybe another for some random service you have that returns a promise. All these on different parts of the UI. Heck, maybe you don't even want a spinner. You just want to know while http requests of some type are pending.

Well, sigh no more my dear friend, your troubles are over.

We now have an easy solution for ya! Here's how it looks.

  • Throw a promiseTracker onto your scope
function MyCtrl($scope, promiseTracker) {
  $scope.pizzaTracker = promiseTracker('pizza');
}

  • Do some requests, and in their config add in a little option called tracker
$http.get('/pizzaFlavor', { tracker: 'pizza' });
$http.get('/pizzaType', { tracker: 'pizza' });
$http.get('/pizzaCrust', { tracker: 'pizza' });

  • Now the awesomes happen: pizzaTracker.active() will be true whenever any request with tracker: 'pizza' is waiting for response!
<div ng-show="pizzaTracker.active()" style="background: pink;">
  Loading some pizza data for ya, sir! ...
</div>

  • But wait, there's more! You can also catch cool events when stuff happens on any pizza promise...
$scope.pizzaTracker.on('error', function(response) {
  $scope.pizzaError = "Uh oh, some sort of pizza error happened! " + response.data;
});
$http.get('/pizzaError', { tracker: 'pizza' });
<b ng-show="pizzaError" style="color:red;">!! {{pizzaError}} !!</b>

You can catch any of these events: 'error', 'success', 'start', 'done'. Hopefully they all make sense.

  • Oh, and did I mention... you can attach any old promise to your pizza tracker. Not just http requests!
var myPizzaPromise = $q.defer();
$scope.pizzaTracker.addPromise(myPizzaPromise.promise);

  • You can also have one http request with multiple trackers.
$http.get('/hello', { tracker: ['pizza', 'greetings'] });
@ajoslin ajoslin feat(promiseTracker): add promiseTracker service
Allows tracking of http requests or any promise.
20f2c2a
@ajoslin

I was going to do the demo too, but don't have time right now - I'll have to create a smaller demo than the one I already have made.

@joseym
Owner

crazy awesome

@joseym joseym closed this
@joseym joseym reopened this
@joseym
Owner

also, i shouldn't hit the Close & Comment button

@petebacondarwin
@joshkurz
Owner
@ProLoser
Owner

Mayhaps you should just transfer your repo to the AngularUI namespace @ajoslin?

@glebm
Owner

This is fantastic. It may make sense to expose error() similar to active() as this is a common UI check

@ProLoser ProLoser referenced this pull request in ajoslin/angular-promise-tracker
Closed

Relocate to AngularUI? #5

@ProLoser
Owner

@ajoslin should we just close this PR?

@ProLoser ProLoser closed this
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Feb 19, 2013
  1. @ajoslin

    feat(promiseTracker): add promiseTracker service

    ajoslin authored
    Allows tracking of http requests or any promise.
This page is out of date. Refresh to see the latest.
View
3  common/module.js
@@ -2,4 +2,5 @@
angular.module('ui.config', []).value('ui.config', {});
angular.module('ui.filters', ['ui.config']);
angular.module('ui.directives', ['ui.config']);
-angular.module('ui', ['ui.filters', 'ui.directives', 'ui.config']);
+angular.module('ui.services', ['ui.config']);
+angular.module('ui', ['ui.filters', 'ui.directives', 'ui.services', 'ui.config']);
View
105 modules/services/promise-tracker/promise-tracker.js
@@ -0,0 +1,105 @@
+angular.module('ui.services')
+
+.factory('promiseTracker', ['$q', function($q) {
+ var self = this;
+ var trackers = {};
+
+ function Tracker(name) {
+ var self = this;
+ var numPending = 0;
+ var callbacks = {
+ start: [], //called when a new promise is added
+ done: [], //called when a promise is resolved (error or success)
+ error: [], //called on error
+ success: [] //called on success
+ };
+ self.active = function() {
+ return numPending > 0;
+ };
+ /*
+ * on: add a callback for an event
+ * @param event: 'start', 'done', 'error', or 'success'
+ * @param callback: function() {} to call when event happens
+ * @return function removeCallback: removes listener
+ */
+ self.on = self.bind = function(event, cb) {
+ if (!callbacks[event]) {
+ throw "Cannot create callback for event '" + event +
+ "'. Allowed types: 'start', 'done', 'error', 'success'";
+ }
+ callbacks[event].push(cb);
+ return self; //self for chaining
+ };
+ self.off = self.unbind = function(event, cb) {
+ if (!callbacks[event]) {
+ throw "Cannot create callback for event '" + event +
+ "'. Allowed types: 'start', 'done', 'error', 'success'";
+ }
+ //If a callback is given, remove just that callback
+ if (cb) {
+ var idx = callbacks[event].indexOf(cb);
+ callbacks[event].splice(cb, 1);
+ //If no callback is given, remove all callbacks for this event
+ } else {
+ callbacks[event].length = 0;
+ }
+ return self; //self for chaining
+ };
+
+ function promiseDone(value, isError) {
+ fireEvent('done', [value, isError]);
+ if (isError) fireEvent('error', [value]);
+ else fireEvent('success', [value]);
+ numPending--;
+ }
+ function fireEvent(event, params) {
+ angular.forEach(callbacks[event], function(cb) {
+ cb.apply(self, params || []);
+ });
+ }
+ // Add any old promise to our tracking
+ // startParam is simply a parameter to pass to the 'start' callback
+ // it's usually an http config object
+ self.addPromise = function(promise, startParam) {
+ numPending++;
+ fireEvent('start', [startParam]);
+ return promise.then(function success(value) {
+ promiseDone(value);
+ return value;
+ }, function error(value) {
+ promiseDone(value, true);
+ return $q.reject(value);
+ });
+ };
+ }
+ return function(trackerName) {
+ if (!trackers[trackerName]) trackers[trackerName] = new Tracker(trackerName);
+ return trackers[trackerName];
+ };
+}])
+
+.config(['$httpProvider', function($httpProvider) {
+ $httpProvider.responseInterceptors.push('trackerResponseInterceptor');
+}])
+
+.factory('trackerResponseInterceptor', ['$q', 'promiseTracker', '$injector',
+function($q, promiseTracker, $injector) {
+ //We use $injector get around circular dependency problem for $http
+ var $http;
+ return function spinnerResponseInterceptor(promise) {
+ if (!$http) $http = $injector.get('$http');
+
+ //We know the latest request is always going to be last in the list
+ var requestConfig = $http.pendingRequests[$http.pendingRequests.length-1];
+ var trackerConfig;
+ if ((trackerConfig = requestConfig.tracker)) {
+ if (!angular.isArray(trackerConfig)) {
+ trackerConfig = [trackerConfig];
+ }
+ angular.forEach(trackerConfig, function(trackerName) {
+ promiseTracker(trackerName).addPromise(promise, requestConfig);
+ });
+ }
+ return promise;
+ };
+}]);
View
261 modules/services/promise-tracker/test/promise-tracker.spec.js
@@ -0,0 +1,261 @@
+describe('Promise Tracker', function() {
+ beforeEach(module('ui.services'));
+
+ var promiseTracker, $httpBackend, $http, $q, $rootScope;
+ beforeEach(inject(function(_promiseTracker_, _$httpBackend_, _$http_, _$q_, _$rootScope_) {
+ promiseTracker = _promiseTracker_;
+ $httpBackend = _$httpBackend_;
+ $http = _$http_;
+ $q = _$q_;
+ $rootScope = _$rootScope_;
+ }));
+
+ function digest() {
+ $rootScope.$apply();
+ }
+
+ describe('factory', function() {
+ var myTracker;
+ beforeEach(function() {
+ myTracker = promiseTracker('myTracker');
+ });
+
+ it('should create a new tracker', function() {
+ expect(myTracker).toBeTruthy();
+ });
+
+ it('should get the tracker each time myTracker(name) is called', function() {
+ expect(promiseTracker('myTracker')).toBe(myTracker);
+ });
+
+ it('should be inactive at start', function() {
+ expect(myTracker.active()).toBe(false);
+ });
+
+ it('should add a promise and return it', function() {
+ var deferred = $q.defer();
+ expect(typeof myTracker.addPromise(deferred.promise)).toBe('object');
+ });
+
+ it('should allow you to add callbacks of the right type', function() {
+ myTracker.on('start', angular.noop);
+ myTracker.on('success', angular.noop);
+ myTracker.on('error', angular.noop);
+ myTracker.on('done', angular.noop);
+ });
+
+ it('should throw error if you add callback of wrong type', function() {
+ expect(function() { myTracker.on('sarayu', angular.noop); }).toThrow();
+ });
+
+ it('should allow you to remove callbacks of the right type', function() {
+ myTracker.off('start');
+ myTracker.off('success');
+ myTracker.off('error');
+ myTracker.off('done');
+ });
+
+ it('should throw error if you remove callback of wrong type', function() {
+ expect(function() { myTracker.off('sarayu'); }).toThrow();
+ });
+
+ describe('after adding a promise', function() {
+ var deferred;
+ beforeEach(function() {
+ deferred = $q.defer();
+ myTracker.addPromise(deferred.promise);
+ });
+
+ it('should be active at first', function() {
+ expect(myTracker.active()).toBe(true);
+ });
+ it('should be inactive after resolving promise', function() {
+ deferred.resolve();
+ digest();
+ expect(myTracker.active()).toBe(false);
+ });
+ it('should be inactive after rejecting promise', function() {
+ deferred.reject();
+ digest();
+ expect(myTracker.active()).toBe(false);
+ });
+ it('should stay active while at least one promise is active', function() {
+ var d1 = $q.defer();
+ myTracker.addPromise(d1.promise);
+ expect(myTracker.active()).toBe(true);
+ d1.resolve();
+ digest();
+ expect(myTracker.active()).toBe(true);
+ deferred.reject();
+ digest();
+ expect(myTracker.active()).toBe(false);
+ });
+
+ describe('events', function() {
+ var events = ['success','error','done','start'];
+ var callbacks, count;
+
+ beforeEach(function() {
+ callbacks = {}, count = {};
+ //Automatically create callbacks for all events, which just add to
+ //a count for that event
+ angular.forEach(events, function(event) {
+ count[event] = 0;
+ callbacks[event] = function(amount) { count[event] += amount; };
+ });
+ angular.forEach(events, function(e) {
+ myTracker.on(e, callbacks[e]);
+ });
+ });
+
+ it('should fire "start" with param when promise is added', function() {
+ expect(count.start).toBe(0);
+ myTracker.addPromise($q.defer().promise, 3);
+ expect(count.start).toBe(3);
+ });
+ it('should fire "done" with param when promise is resolved or rejected', function() {
+ expect(count.done).toBe(0);
+ var deferred = $q.defer();
+ myTracker.addPromise(deferred.promise);
+ deferred.resolve(11);
+ digest();
+ expect(count.done).toBe(11);
+ deferred = $q.defer();
+ myTracker.addPromise(deferred.promise);
+ deferred.reject(22);
+ digest();
+ expect(count.done).toBe(33);
+ });
+ it('should fire "error" with param when promise is rejected', function() {
+ expect(count.error).toBe(0);
+ var deferred = $q.defer();
+ myTracker.addPromise(deferred.promise);
+ deferred.reject(7);
+ digest();
+ expect(count.error).toBe(7);
+ });
+ it('should fire "success" with param when promise is resolve', function() {
+ expect(count.success).toBe(0);
+ var deferred = $q.defer();
+ myTracker.addPromise(deferred.promise);
+ deferred.resolve(9);
+ digest();
+ expect(count.success).toBe(9);
+ });
+ it('should fire all events, at right times', function() {
+ var deferred = $q.defer();
+ myTracker.addPromise(deferred.promise, 5);
+ expect(count.start).toBe(5);
+ deferred.resolve(6);
+ digest();
+ expect(count.done).toBe(6);
+ expect(count.success).toBe(6);
+ expect(count.error).toBe(0);
+ expect(count.start).toBe(5);
+ deferred = $q.defer();
+ myTracker.addPromise(deferred.promise, 7);
+ expect(count.start).toBe(12);
+ deferred.reject(8);
+ digest();
+ expect(count.done).toBe(14);
+ expect(count.success).toBe(6);
+ expect(count.error).toBe(8);
+ expect(count.start).toBe(12);
+ });
+ it('should unbind only given function', function() {
+ var called;
+ myTracker.on('start', function() {
+ called = true;
+ });
+ myTracker.off('start', callbacks.start);
+ var deferred = $q.defer();
+ myTracker.addPromise(deferred.promise, 5);
+ expect(count.start).toBe(0);
+ expect(called).toBeTruthy();
+ });
+ it('should unbind all functions if none given', function() {
+ var called;
+ myTracker.on('start', function() {
+ called = true;
+ });
+ myTracker.off('start');
+ var deferred = $q.defer();
+ myTracker.addPromise(deferred.promise, 5);
+ expect(count.start).toBe(0);
+ expect(called).toBeFalsy();
+ });
+ });
+ });
+ });
+
+ describe('http interceptor', function() {
+ var tracky;
+ beforeEach(function() {
+ tracky = promiseTracker('tracky');
+ $httpBackend.whenGET("/pizza").respond("pepperoni");
+ $httpBackend.whenGET("/pie").respond("apple");
+ $httpBackend.whenGET("/error").respond(500, "monkeys");
+ });
+
+ it('should not track an http request with no tracker option', function() {
+ $http.get('/pizza');
+ expect(tracky.active()).toBe(false);
+ $httpBackend.flush();
+ expect(tracky.active()).toBe(false);
+ });
+
+ it('should track an http request with tracker option', function() {
+ $http.get('/pizza', { tracker: 'tracky' });
+ expect(tracky.active()).toBe(true);
+ $httpBackend.flush();
+ expect(tracky.active()).toBe(false);
+ });
+
+ it('should create a new tracker if http request gives new name', function() {
+ $http.get('/pizza', { tracker: 'jonny' });
+ expect(promiseTracker('jonny').active()).toBe(true);
+ $httpBackend.flush();
+ expect(promiseTracker('jonny').active()).toBe(false);
+ });
+
+ it('should bind to two trackers if an array of trackers is given', function() {
+ var t1 = promiseTracker('t1');
+ var t2 = promiseTracker('t2');
+ $http.get('/pizza', { tracker: ['t1', 't2'] });
+ expect(t1.active()).toBe(true);
+ expect(t2.active()).toBe(true);
+ $httpBackend.flush();
+ expect(t1.active()).toBeFalsy();
+ expect(t2.active()).toBeFalsy();
+ });
+
+ describe('binding events', function() {
+ var callbacks, responses;
+ beforeEach(function() {
+ var events = ['success','start','done','error'];
+ callbacks = {}, responses = {};
+ angular.forEach(events, function(e) {
+ responses[e] = [];
+ callbacks[e] = function(response) {
+ responses[e].push(response);
+ };
+ tracky.on(e, callbacks[e]);
+ });
+ });
+
+ it('should call success, start, done callbacks', function() {
+ $http.get('/pizza', { tracker: 'tracky' });
+ expect(responses.start[0].url).toBe('/pizza');
+ $httpBackend.flush();
+ expect(responses.done[0].data).toBe('pepperoni');
+ expect(responses.success[0].data).toBe('pepperoni');
+ });
+ it('should call success and error callbacks', function() {
+ $http.get('/error', { tracker: 'tracky' });
+ $httpBackend.flush();
+ expect(responses.done[0].data).toBe('monkeys');
+ expect(responses.error[0].data).toBe('monkeys');
+ });
+ });
+ });
+});
Something went wrong with that request. Please try again.