├── .gitignore ├── LICENSE ├── README.md ├── async-filter.js ├── bower.json ├── karma.conf.js ├── package.json ├── src └── async-filter.js └── test └── async-filter.spec.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Carl Vuorinen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Angular1 async filter 2 | 3 | > Angular2 async pipe implemented as Angular 1 filter to handle Promises & RxJS Observables 4 | 5 | The async filter takes a Promise or Observable as input and subscribes to the input automatically, eventually returning the emitted value(s) similarly as with Angular2 Async pipe. 6 | Works with RxJS Observables even without https://github.com/Reactive-Extensions/rx.angular.js 7 | 8 | ## Install 9 | 10 | Install from npm: 11 | 12 | ``` 13 | npm install angular1-async-filter --save 14 | ``` 15 | 16 | And bundle with Browserify, Webpack etc. 17 | 18 | 19 | Or install with bower: 20 | 21 | ``` 22 | bower install angular1-async-filter --save 23 | ``` 24 | 25 | And add script to html: 26 | 27 | ```html 28 | 29 | ``` 30 | 31 | Add Angular module dependency. For example: 32 | 33 | ```js 34 | angular.module('myApp', ['asyncFilter']); 35 | ``` 36 | 37 | ## Usage 38 | 39 | Basic usage: 40 | 41 | ```html 42 |
Value: {{ promiseOrObservable | async }}
43 | ``` 44 | 45 | Works with any directive that takes an expression, like `ng-if`, `ng-show` and `ng-repeat` etc. 46 | 47 | ```html 48 | 51 | ``` 52 | 53 | Angular $q and $http promises automatically trigger digest cycle when they resolve so all views will get updated. 54 | For compatibility with RxJS Observables, as well as other Promise or Observable implementations, you can provide the current scope as a parameter to the filter, which will then automatically trigger digest cycle whenever a new value is emitted: 55 | 56 | ```html 57 |
58 | Value: {{ observable | async:this }} 59 |
60 | ``` 61 | 62 | If you are using the `safeApply` operator from https://github.com/Reactive-Extensions/rx.angular.js for example, then `this` is not needed. 63 | 64 | Live example: http://jsbin.com/qoxaqo/edit?html,js,output 65 | 66 | ## License 67 | 68 | MIT 69 | -------------------------------------------------------------------------------- /async-filter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function (angular) { 4 | function asyncFilter() { 5 | var values = {}; 6 | var subscriptions = {}; 7 | 8 | function async(input, scope) { 9 | // Make sure we have an Observable or a Promise 10 | if (!input || !(input.subscribe || input.then)) { 11 | return input; 12 | } 13 | 14 | var inputId = objectId(input); 15 | if (!(inputId in subscriptions)) { 16 | var subscriptionStrategy = input.subscribe && input.subscribe.bind(input) || input.success && input.success.bind(input) // To make it work with HttpPromise 17 | || input.then.bind(input); 18 | 19 | subscriptions[inputId] = subscriptionStrategy(function (value) { 20 | values[inputId] = value; 21 | 22 | if (scope && scope.$applyAsync) { 23 | scope.$applyAsync(); // Automatic safe apply, if scope provided 24 | } 25 | }); 26 | 27 | if (scope && scope.$on) { 28 | // Clean up subscription and its last value when the scope is destroyed. 29 | scope.$on('$destroy', function () { 30 | var sub = subscriptions[inputId]; 31 | if (sub) { 32 | sub.unsubscribe && sub.unsubscribe(); 33 | sub.dispose && sub.dispose(); 34 | } 35 | delete subscriptions[inputId]; 36 | delete values[inputId]; 37 | }); 38 | } 39 | } 40 | 41 | return values[inputId]; 42 | }; 43 | 44 | // Need a way to tell the input objects apart from each other (so we only subscribe to them once) 45 | var nextObjectID = 0; 46 | function objectId(obj) { 47 | if (!obj.hasOwnProperty('__asyncFilterObjectID__')) { 48 | obj.__asyncFilterObjectID__ = ++nextObjectID; 49 | } 50 | 51 | return obj.__asyncFilterObjectID__; 52 | } 53 | 54 | // So that Angular does not cache the return value 55 | async.$stateful = true; 56 | 57 | return async; 58 | }; 59 | 60 | angular.module('asyncFilter', []).filter('async', asyncFilter); 61 | })(angular); 62 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular1-async-filter", 3 | "description": "Angular2 async pipe implemented as Angular 1 filter to handle promises & RxJS observables", 4 | "main": "async-filter.js", 5 | "authors": [ 6 | "Carl Vuorinen" 7 | ], 8 | "license": "MIT", 9 | "keywords": [ 10 | "angular", 11 | "async", 12 | "filter", 13 | "promise", 14 | "observable", 15 | "rxjs" 16 | ], 17 | "homepage": "https://github.com/cvuorinen/angular1-async-filter", 18 | "moduleType": [], 19 | "ignore": [ 20 | "**/.*", 21 | "node_modules", 22 | "bower_components", 23 | "test", 24 | "tests" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // Generated on Wed Jun 15 2016 23:16:37 GMT+0300 (EEST) 3 | 4 | module.exports = function(config) { 5 | config.set({ 6 | 7 | // base path that will be used to resolve all patterns (eg. files, exclude) 8 | basePath: '', 9 | 10 | 11 | // frameworks to use 12 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 13 | frameworks: ['jasmine'], 14 | 15 | 16 | // list of files / patterns to load in the browser 17 | files: [ 18 | 'node_modules/phantomjs-polyfill/bind-polyfill.js', 19 | 'node_modules/angular/angular.js', 20 | 'node_modules/angular-mocks/angular-mocks.js', 21 | 'async-filter.js', 22 | 'test/**/*.spec.js' 23 | ], 24 | 25 | 26 | // list of files to exclude 27 | exclude: [ 28 | ], 29 | 30 | 31 | // preprocess matching files before serving them to the browser 32 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 33 | preprocessors: { 34 | }, 35 | 36 | 37 | // test results reporter to use 38 | // possible values: 'dots', 'progress' 39 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 40 | reporters: ['progress'], 41 | 42 | 43 | // web server port 44 | port: 9876, 45 | 46 | 47 | // enable / disable colors in the output (reporters and logs) 48 | colors: true, 49 | 50 | 51 | // level of logging 52 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 53 | logLevel: config.LOG_INFO, 54 | 55 | 56 | // enable / disable watching file and executing tests whenever any file changes 57 | autoWatch: true, 58 | 59 | 60 | // start these browsers 61 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 62 | browsers: ['PhantomJS'], 63 | 64 | 65 | // Continuous Integration mode 66 | // if true, Karma captures browsers, runs the tests and exits 67 | singleRun: false, 68 | 69 | // Concurrency level 70 | // how many browser should be started simultaneous 71 | concurrency: Infinity 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular1-async-filter", 3 | "version": "1.1.0", 4 | "description": "Angular2 async pipe implemented as Angular 1 filter to handle promises & RxJS observables", 5 | "main": "async-filter.js", 6 | "scripts": { 7 | "build": "babel src/async-filter.js -o async-filter.js --presets 'es2015'", 8 | "test": "./node_modules/karma/bin/karma start --single-run" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/cvuorinen/angular1-async-filter.git" 13 | }, 14 | "keywords": [ 15 | "angular", 16 | "async", 17 | "filter", 18 | "promise", 19 | "observable", 20 | "rxjs" 21 | ], 22 | "author": "Carl Vuorinen", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/cvuorinen/angular1-async-filter/issues" 26 | }, 27 | "homepage": "https://github.com/cvuorinen/angular1-async-filter#readme", 28 | "devDependencies": { 29 | "angular": "^1.5.7", 30 | "angular-mocks": "^1.5.7", 31 | "babel-cli": "^6.5.1", 32 | "babel-preset-es2015": "^6.5.0", 33 | "jasmine": "^2.4.1", 34 | "karma": "^0.13.22", 35 | "karma-jasmine": "^1.0.2", 36 | "karma-phantomjs-launcher": "^1.0.0", 37 | "phantomjs-polyfill": "0.0.2", 38 | "phantomjs-prebuilt": "^2.1.7" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/async-filter.js: -------------------------------------------------------------------------------- 1 | (function (angular) { 2 | function asyncFilter() { 3 | const values = {}; 4 | const subscriptions = {}; 5 | 6 | function async(input, scope) { 7 | // Make sure we have an Observable or a Promise 8 | if (!input || !(input.subscribe || input.then)) { 9 | return input; 10 | } 11 | 12 | const inputId = objectId(input); 13 | if (!(inputId in subscriptions)) { 14 | const subscriptionStrategy = input.subscribe && input.subscribe.bind(input) 15 | || input.success && input.success.bind(input) // To make it work with HttpPromise 16 | || input.then.bind(input); 17 | 18 | subscriptions[inputId] = subscriptionStrategy(value => { 19 | values[inputId] = value; 20 | 21 | if (scope && scope.$applyAsync) { 22 | scope.$applyAsync(); // Automatic safe apply, if scope provided 23 | } 24 | }); 25 | 26 | if (scope && scope.$on) { 27 | // Clean up subscription and its last value when the scope is destroyed. 28 | scope.$on('$destroy', () => { 29 | const sub = subscriptions[inputId]; 30 | if (sub) { 31 | sub.unsubscribe && sub.unsubscribe(); 32 | sub.dispose && sub.dispose(); 33 | } 34 | delete subscriptions[inputId]; 35 | delete values[inputId]; 36 | }); 37 | } 38 | } 39 | 40 | return values[inputId]; 41 | }; 42 | 43 | // Need a way to tell the input objects apart from each other (so we only subscribe to them once) 44 | let nextObjectID = 0; 45 | function objectId(obj) { 46 | if (!obj.hasOwnProperty('__asyncFilterObjectID__')) { 47 | obj.__asyncFilterObjectID__ = ++nextObjectID; 48 | } 49 | 50 | return obj.__asyncFilterObjectID__; 51 | } 52 | 53 | // So that Angular does not cache the return value 54 | async.$stateful = true; 55 | 56 | return async; 57 | }; 58 | 59 | angular 60 | .module('asyncFilter', []) 61 | .filter('async', asyncFilter); 62 | 63 | })(angular); 64 | -------------------------------------------------------------------------------- /test/async-filter.spec.js: -------------------------------------------------------------------------------- 1 | describe('Filter: async', function () { 2 | var asyncFilter, 3 | unresolvedPromise = function() { 4 | return { then: function() {} }; 5 | }, 6 | resolvedPromise = function(value) { 7 | return { then: function(callback) { callback(value); } }; 8 | }, 9 | promiseMock = function() { 10 | var callback; 11 | return { 12 | then: function(callbackFn) { callback = callbackFn; }, 13 | resolve: function (value) { callback(value); } 14 | }; 15 | }, 16 | observableMock = function(subscription) { 17 | var callback; 18 | return { 19 | subscribe: function(callbackFn) { 20 | callback = callbackFn; 21 | 22 | return subscription; 23 | }, 24 | next: function (value) { callback(value); } 25 | }; 26 | }, 27 | subscriptionMock = function(unsubscribeFunctionName) { 28 | var subscription = {}; 29 | subscription[unsubscribeFunctionName] = function() {}; 30 | 31 | return subscription; 32 | }; 33 | 34 | beforeEach(module('asyncFilter')); 35 | beforeEach(inject(function ($filter) { 36 | asyncFilter = $filter('async'); 37 | })); 38 | 39 | it('should return null on null input', function() { 40 | expect(asyncFilter(null)).toEqual(null); 41 | }); 42 | 43 | it('should return input when it is not Promise or Observable', function() { 44 | expect(asyncFilter("foo")).toEqual("foo"); 45 | }); 46 | 47 | it('should return undefined when promise is not resolved', function() { 48 | expect(asyncFilter(unresolvedPromise())).toEqual(undefined); 49 | }); 50 | 51 | it('should return promises resolved value', function() { 52 | expect(asyncFilter(resolvedPromise(42))).toEqual(42); 53 | }); 54 | 55 | it('should return undefined until promise resolved', function() { 56 | var promise = promiseMock(); 57 | 58 | expect(asyncFilter(promise)).toEqual(undefined); 59 | 60 | promise.resolve(42); 61 | 62 | expect(asyncFilter(promise)).toEqual(42); 63 | }); 64 | 65 | it('should return falsy value', function() { 66 | expect(asyncFilter(resolvedPromise(0))).toEqual(0); 67 | }); 68 | 69 | it('should return undefined until observable emits', function() { 70 | var observable = observableMock(); 71 | 72 | expect(asyncFilter(observable)).toEqual(undefined); 73 | 74 | observable.next(42); 75 | 76 | expect(asyncFilter(observable)).toEqual(42); 77 | }); 78 | 79 | it('should return observables latest value', function() { 80 | var observable = observableMock(); 81 | asyncFilter(observable); 82 | 83 | observable.next(42); 84 | 85 | expect(asyncFilter(observable)).toEqual(42); 86 | 87 | observable.next("foo"); 88 | 89 | expect(asyncFilter(observable)).toEqual("foo"); 90 | }); 91 | 92 | it('should only subscribe once', function() { 93 | var observable = observableMock(); 94 | spyOn(observable, 'subscribe'); 95 | 96 | asyncFilter(observable); 97 | asyncFilter(observable); 98 | asyncFilter(observable); 99 | 100 | expect(observable.subscribe).toHaveBeenCalled(); 101 | expect(observable.subscribe.calls.count()).toEqual(1); 102 | }); 103 | 104 | it('should call $applyAsync on each value if scope provided', function() { 105 | var observable = observableMock(); 106 | var scope = { 107 | $applyAsync: jasmine.createSpy('scope.$applyAsync') 108 | }; 109 | 110 | expect(asyncFilter(observable, scope)).toEqual(undefined); 111 | expect(scope.$applyAsync).not.toHaveBeenCalled(); 112 | 113 | observable.next(42); 114 | 115 | expect(scope.$applyAsync).toHaveBeenCalled(); 116 | expect(scope.$applyAsync.calls.count()).toEqual(1); 117 | 118 | observable.next("foo"); 119 | 120 | expect(scope.$applyAsync.calls.count()).toEqual(2); 121 | }); 122 | 123 | it('should listen for scope $destroy event if scope provided', function() { 124 | var observable = observableMock(); 125 | var scope = { 126 | $on: jasmine.createSpy('scope.$on') 127 | }; 128 | 129 | asyncFilter(observable, scope); 130 | 131 | expect(scope.$on).toHaveBeenCalledWith('$destroy', jasmine.any(Function)); 132 | }); 133 | 134 | it('should dispose subscription on scope $destroy event', function() { 135 | // RxJS 4 style unsubscribe 136 | var subscription = subscriptionMock('dispose'); 137 | spyOn(subscription, 'dispose'); 138 | var observable = observableMock(subscription); 139 | var onDestroy; 140 | var scope = { 141 | $on: function(event, callback) { onDestroy = callback; } 142 | }; 143 | 144 | asyncFilter(observable, scope); 145 | 146 | expect(subscription.dispose).not.toHaveBeenCalled(); 147 | 148 | onDestroy(); 149 | 150 | expect(subscription.dispose).toHaveBeenCalled(); 151 | }); 152 | 153 | it('should unsubscribe subscription on scope $destroy event', function() { 154 | // RxJS 5 support 155 | var subscription = subscriptionMock('unsubscribe'); 156 | spyOn(subscription, 'unsubscribe'); 157 | var observable = observableMock(subscription); 158 | var onDestroy; 159 | var scope = { 160 | $on: function(event, callback) { onDestroy = callback; } 161 | }; 162 | 163 | asyncFilter(observable, scope); 164 | 165 | expect(subscription.unsubscribe).not.toHaveBeenCalled(); 166 | 167 | onDestroy(); 168 | 169 | expect(subscription.unsubscribe).toHaveBeenCalled(); 170 | }); 171 | }); 172 | --------------------------------------------------------------------------------