├── .gitignore ├── LICENSE ├── README.md ├── karma.conf.js ├── package.json ├── src └── smart-collection.js └── tests └── smart-collection-test.js /.gitignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 by StackEngine 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SmartCollection for AngularJS 2 | 3 | The SmartCollection service is a way to do CRUD operations on a collection of 4 | objects through an API while maintaining a consistent local cache. 5 | 6 | The key benefit of SmartCollection over most other CRUD tools is the 7 | consistent local cache. We make sure all object references are kept the same. 8 | This cache can be referenced in Angular scopes such that any time the the 9 | collection updates an object, the scope will automatically be re-rendered 10 | (since the object references will not change). 11 | 12 | $scope.users = UserCollection.items(); 13 | UserCollection.getAll(); 14 | 15 | At first, UserCollection.items() will return an empty array and later, 16 | asynchronously, getAll() will populate that array. But the array reference 17 | will stay the same and never change. 18 | 19 | All of the routes in SmartController are 100% user configured, we do not 20 | assume anything about your API. Every route will get its own function in the SmartCollection instance. 21 | 22 | ### Function Reference 23 | 24 | There are only two functions that SmartCollection provides inherently: 25 | 26 | * **items()** - Returns a reference to the collection array. It will always 27 | return the *same* reference to the *same* array every time it is called. You 28 | should *not* add or remove items from this array directly. 29 | 30 | * **item(id)** - Returns a reference to a single model object from the collection, 31 | referenced by "id". If no match is found, null will be returned. 32 | 33 | ### Configuration 34 | 35 | * Required parameters 36 | * **routes** - A hash representing all defined routes. See the "Routing" section below. 37 | * **key** - A string representing the key name used to uniquely identify your model objects. 38 | For example, 'id' if model.id is the primary key on your model. Or 'UserId' for obj.UserId. 39 | * Optional parameters 40 | * **model** - A model object to be used. If none is provided, GenericModel will be used which is defined by SmartCollection. The constructor function must take just one argument, a plain JavaScript object with all your model attributes. 41 | 42 | ### Routing 43 | 44 | Every route you define is given its own function on the SmartCollection instance. In the example below, UserCollection.update() is a function created because the "update" route was define. The route function accepts 0 or 1 parameters. If a parameter is given, it is assumed to be a model object. 45 | 46 | When defining routes, these are the availble parameters: 47 | 48 | * Required parameters 49 | * **url** - The URL for this API call. You can use colon-denoted parameters to add attributes from a model object. By default each attribute will be pulled from the model object, but the names can be overridden by the urlKeys option. Example: "/users.json" or "/users/:id.json". In this second case, the model.id value will be used in place of :id. 50 | * **method** - Any HTTP method accepted by $http. Example: "get", "post", "delete", etc. 51 | 52 | * Optional parameters 53 | * **responseType** - String. Determines what happens when a successful response is received. Default: 'ignore' 54 | * "_ignore_" - Do nothing. (default) 55 | * "_array_" - Response is an array of items that represents the entire collection. New items will be created and added to the collection, existing items will have attributes merged, and anything in the existing collection that does not exist in the response array will be removed. 56 | * "_one_" - Response is a single item. Using its primary key, it will be added or merged into the current collection. 57 | * "_remove_" - After a successful response is received, the item operated on by this route will be removed from the local collection array. 58 | * **responsePrefix** - String. If present, all response data will be assumed to have this prefix. For example, your "users" API may return ```{users: [...]}```. By setting responsePrefix to "users", SmartCollection will know where to look for the actual data. 59 | * **urlKeys** - A hash that maps model attributes to url parameters. This is only necessary if the colon-delimited name in the URL is different from the model attribute name. Example: ```{ID: 'id'}``` 60 | * **transformRequestData(item)** - A function that transforms a model before it is sent as they parameter payload on the $http request. This should return a plain JavaScript object with all the attributes you want to send to the HTTP request. 61 | * **transformResponseData(responseData, item)** - A function that transforms the response.data from $http before it is turned into a model object by SmartCollection. This should return a plain JavaScript object with all the attributes (and any changes) you want to store in the model. 62 | 63 | ### Example 64 | 65 | app.factory('UserCollection', function(SmartCollection, UserModel) { 66 | return new SmartCollection({ 67 | key: "id", 68 | model: UserModel, 69 | routes: { 70 | getAll: { 71 | method: 'get', 72 | responseType: 'array', 73 | url: '/users' 74 | }, 75 | update: { 76 | method: 'put', 77 | responseType: 'one', 78 | url: '/users/:id', 79 | urlKeys: {id: 'id'} 80 | } 81 | } 82 | }) 83 | }); 84 | 85 | app.factory('UserModel', function() { 86 | var UserModel = function(attrs) { 87 | this.id = attrs.id; 88 | this.firstName = attrs.first_name; 89 | this.lastName = attrs.last_name; 90 | }; 91 | 92 | UserModel.prototype.fullName = function() { 93 | return this.firstName+' '+this.lastName; 94 | }; 95 | 96 | return UserModel; 97 | }); 98 | 99 | app.controller('UsersController', function(UserCollection, $scope) { 100 | $scope.users = UserCollection.items(); 101 | $scope.updateUser = function(user) { 102 | UserCollection.update(user).then( 103 | /* success */ 104 | function() { 105 | alert(user.fullName()+' was saved successfully."); 106 | }, 107 | /* error */ 108 | function(response) { 109 | alert(user.fullName()+' could not be saved. Server said: '+response.data); 110 | } 111 | ); 112 | }; 113 | 114 | // Lazy-load the collection asynchronously. 115 | UserCollection.getAll(); 116 | }); 117 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config){ 2 | config.set({ 3 | 4 | basePath : '', 5 | 6 | files : [ 7 | 'node_modules/angular/angular.js', 8 | 'node_modules/angular-mocks/angular-mocks.js', 9 | 'src/smart-collection.js', 10 | 'tests/*.js' 11 | ], 12 | 13 | autoWatch : true, 14 | 15 | frameworks: ['jasmine'], 16 | 17 | browsers: ['PhantomJS'], 18 | 19 | plugins : [ 20 | 'karma-phantomjs-launcher', 21 | 'karma-jasmine' 22 | ] 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-smart-collection", 3 | "version": "0.9.1", 4 | "author": "Luke Ehresman ", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "git://github.com/stackengine/angular-smart-collection.git" 9 | }, 10 | "dependencies": { 11 | "angular": "~1.3" 12 | }, 13 | "devDependencies": { 14 | "angular-mocks": "~1.3", 15 | "karma": "^0.12.31", 16 | "karma-jasmine": "^0.3.5", 17 | "karma-phantomjs-launcher": "^0.1.4" 18 | }, 19 | "scripts": { 20 | "test": "karma start --single-run" 21 | }, 22 | "keywords": [ 23 | "angular", 24 | "collection" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/smart-collection.js: -------------------------------------------------------------------------------- 1 | /* SmartCollection 2 | * (c) 2015 by StackEngine under the MIT license 3 | */ 4 | 5 | angular.module('SmartCollection', []) 6 | .factory('SmartCollection', function($http){ 7 | return function(config) { 8 | 9 | var loaded = false; 10 | var routes = config.routes || {}; 11 | var key = config.key || 'id'; 12 | var items = []; 13 | var itemIndex = {}; 14 | var pendingItems = {}; 15 | var promises = {}; 16 | var model = config.model || (function GenericModel(attrs) { 17 | var self = this; 18 | angular.forEach(attrs, function(value, attr) { 19 | self[attr] = value; 20 | }); 21 | }); 22 | 23 | if (typeof key == 'string') 24 | key = [key]; 25 | 26 | // PRIVATE INTERFACE ------------------------------------------------ 27 | 28 | var performRequest = function(routeName, item) { 29 | var collection = this; 30 | var route = routes[routeName]; 31 | 32 | 33 | // Do some sanity checking to make sure required values exist. 34 | if (!route) throw "Unknown route named '"+routeName+"'"; 35 | angular.forEach(['url', 'method'], function(attr) { 36 | if (!route[attr]) { 37 | throw "Route '"+routeName+"' does not have required parameter: "+attr; 38 | } 39 | }); 40 | 41 | // Convert plain a plain key item to an object so we support both query types 42 | if (typeof item !== 'object') { 43 | var obj = {}; 44 | obj[key] = item; 45 | item = obj; 46 | } 47 | 48 | // Compose the URL we will be using. 49 | var url = route.url; 50 | if (typeof url == 'function') { 51 | url = route.url.call(); 52 | } 53 | url = composeUrl(item, url, route.urlKeys); 54 | 55 | // If a request is already in process for this route, lets just 56 | // piggyback. Instead of issuing another request, just return the 57 | // previous promise. 58 | var promiseKey = route.method+' '+url; 59 | if (promises[promiseKey]) { 60 | return promises[promiseKey]; 61 | } 62 | 63 | // Transform the parameters if necessary. 64 | var params = angular.copy(item); 65 | if (route.transformRequestData) { 66 | params = route.transformRequestData(item); 67 | } 68 | if (route.requestPrefix) { 69 | var newParams = {}; 70 | newParams[route.requestPrefix] = params; 71 | params = newParams; 72 | } 73 | 74 | var promise = $http[route.method](url, params) 75 | .then(function(response) { 76 | var data = response.data; 77 | if (route.responsePrefix) { 78 | data = data[route.responsePrefix]; 79 | } 80 | if (route.transformResponseData) { 81 | data = route.transformResponseData(response.data, item) 82 | } 83 | 84 | // If the keys do not exist in the response, add them 85 | angular.forEach(key, function(k) { 86 | if (typeof data[k] == 'undefined') 87 | data[k] = params[k]; 88 | }); 89 | 90 | // GET requests will set loaded to true. This is just a convenience 91 | // way to know if items have been retrieved. 92 | if (route.method.toLowerCase() == 'get') { 93 | loaded = true; 94 | } 95 | 96 | if (route.responseType == 'array') { 97 | updateAllItems(data); 98 | rval = items; 99 | } else if (route.responseType == 'one') { 100 | updateOneItem(data); 101 | rval = indexLookup(itemIndex, data); 102 | } else if (route.responseType == 'remove') { 103 | // Ignores the response from the API but removes the item from our 104 | // collection. 105 | removeItem(item); 106 | rval = items; 107 | } else if (route.responseType == 'ignore' || typeof response.routeType == 'undefined') { 108 | // By default we will ignore everything sent back from the API. 109 | rval = data; 110 | } else { 111 | throw "Unknown route responseType '"+route.responseType+"' for route "+routeName; 112 | } 113 | 114 | sortCollection(); 115 | return rval; 116 | }) 117 | .finally(function() { 118 | // clean up after ourselves -- since this request is complete, remove 119 | // our cached promise reference so future requests to this route will 120 | // generate a new request. 121 | delete promises[promiseKey]; 122 | }); 123 | 124 | promises[promiseKey] = promise; 125 | return promise; 126 | }; 127 | 128 | var sortCollection = function() { 129 | if (config.sort) 130 | items.sort(config.sort); 131 | }; 132 | 133 | var updateAllItems = function(data) { 134 | // Add new items and update existing items with new values 135 | var currentKeys = {}; 136 | angular.forEach(data, function(item) { 137 | var model = updateOneItem(item); 138 | indexStore(currentKeys, model) 139 | }); 140 | // Remove items from the array and index. 141 | for (var i=0; i < items.length; i++) { 142 | var currentItem = items[i] 143 | if (!indexLookup(currentKeys, currentItem)) { 144 | items.splice(i, 1); 145 | indexRemove(itemIndex, currentItem); 146 | i--; // decrement since we removed one value from the array 147 | } 148 | } 149 | }; 150 | 151 | var updateOneItem = function(data) { 152 | var item = new model(data); 153 | return injectItem(item); 154 | }; 155 | 156 | var removeItem = function(item) { 157 | for (var i=0; i < items.length; i++) { 158 | var currentItem = items[i]; 159 | if (currentItem[key] == item[key]) { 160 | items.splice(i, 1); 161 | indexRemove(itemIndex, item); 162 | return; 163 | } 164 | } 165 | }; 166 | 167 | var injectItem = function(item) { 168 | var indexItem; 169 | if (indexItem = indexLookup(itemIndex, item)) { 170 | angular.extend(indexItem, item); 171 | return indexItem; 172 | } else if (indexItem = indexLookup(pendingItems, item)) { 173 | angular.extend(indexItem, item) 174 | items.push(indexItem); 175 | indexStore(itemIndex, indexItem); 176 | indexRemove(pendingItems, indexItem); 177 | return indexItem; 178 | } else { 179 | items.push(item); 180 | indexStore(itemIndex, item); 181 | return item; 182 | } 183 | }; 184 | 185 | // Takes a url pattern and replaces variables with values from item as 186 | // mapped by the keys hash. For example "/users/:id" becomes "/users/3". 187 | var composeUrl = function(item, url, keys) { 188 | var matches; 189 | while (matches = url.match(/:([^\/\?$]+)/)) { 190 | url = url.replace(matches[0], item[matches[1]]); 191 | } 192 | return url; 193 | }; 194 | 195 | var indexLookup = function(indexHandle, obj) { 196 | for (var i=0; i < key.length; i++) { 197 | var k = obj[key[i]]; 198 | if (i == key.length-1) 199 | return indexHandle[k]; 200 | if (!indexHandle[k]) 201 | return; 202 | indexHandle = indexHandle[k]; 203 | } 204 | }; 205 | 206 | var indexStore = function(indexHandle, obj) { 207 | for (var i=0; i < key.length; i++) { 208 | var k = obj[key[i]]; 209 | if (i == key.length-1) 210 | indexHandle[k] = obj; 211 | else if (!indexHandle[k]) 212 | indexHandle[k] = {}; 213 | indexHandle = indexHandle[k]; 214 | } 215 | }; 216 | 217 | var indexRemove = function(indexHandle, obj) { 218 | for (var i=0; i < key.length; i++) { 219 | var k = obj[key[i]]; 220 | if (i == key.length-1) { 221 | delete indexHandle[k]; 222 | } else 223 | indexHandle = indexHandle[k]; 224 | } 225 | }; 226 | 227 | // PUBLIC INTERFACE ------------------------------------------------ 228 | 229 | var SmartCollection = function() {}; 230 | SmartCollection.prototype.items = function() { return items; }; 231 | SmartCollection.prototype.item = function(obj) { 232 | if (typeof obj != 'object') { 233 | newObj = {}; 234 | newObj[key[0]] = obj; 235 | obj = newObj; 236 | } 237 | 238 | var indexItem; 239 | if (typeof (indexItem = indexLookup(itemIndex, obj)) !== 'undefined') { 240 | return indexItem; 241 | } else if (typeof (indexItem = indexLookup(pendingItems, obj)) !== 'undefined') { 242 | return indexItem; 243 | } else { 244 | var pendingObj = new model(obj); 245 | indexStore(pendingItems, pendingObj); 246 | return pendingObj; 247 | } 248 | }; 249 | SmartCollection.prototype.lookup = function(obj) { 250 | if (typeof obj == 'string') { 251 | obj = {}; 252 | obj[key[0]] = obj; 253 | } 254 | return indexLookup(itemIndex, obj); 255 | } 256 | 257 | // Create a function for each route dynamically 258 | angular.forEach(routes, function(route, routeName) { 259 | if (SmartCollection.prototype[routeName]) 260 | throw "Cannot create a route using reserved name '"+routeName+"'"; 261 | SmartCollection.prototype[routeName] = function(item) { 262 | return performRequest(routeName, item); 263 | }; 264 | }); 265 | 266 | // Bootstrap the items with default data if provided 267 | if (config.bootstrap) 268 | updateAllItems(config.bootstrap); 269 | 270 | return new SmartCollection(); 271 | }; 272 | }); 273 | -------------------------------------------------------------------------------- /tests/smart-collection-test.js: -------------------------------------------------------------------------------- 1 | describe('SmartCollection', function() { 2 | 3 | //////////////////////////////////////////////////////////////////////////////// 4 | // Single response 5 | //////////////////////////////////////////////////////////////////////////////// 6 | describe('one response', function() { 7 | var $httpBackend; 8 | var SmartCollection; 9 | var TestCollection; 10 | function TestModel(data) { 11 | angular.extend(this, data); 12 | }; 13 | 14 | beforeEach(module('SmartCollection')); 15 | beforeEach(inject(function(_$httpBackend_, _SmartCollection_) { 16 | SmartCollection = _SmartCollection_; 17 | TestCollection = new SmartCollection({ 18 | model: TestModel, 19 | routes: { 20 | getOne: { 21 | method: 'get', 22 | responseType: 'one', 23 | url: '/test/:id' 24 | } 25 | } 26 | }); 27 | 28 | $httpBackend = _$httpBackend_; 29 | $httpBackend.when('GET', '/test/1').respond({id: 1, name:"one"}); 30 | $httpBackend.when('GET', '/test/2').respond({id: 2, name:"two"}); 31 | })) 32 | 33 | /////////////////////////////////////////////////////////////////////////////// 34 | 35 | it('accepts integer parameter', function() { 36 | TestCollection.getOne(1).then(function() { 37 | expect(TestCollection.item(1).name).toEqual('one'); 38 | }); 39 | $httpBackend.flush(); 40 | }); 41 | 42 | it('accepts object parameter', function() { 43 | TestCollection.getOne({id:2}).then(function() { 44 | expect(TestCollection.item(2).name).toEqual('two'); 45 | }); 46 | $httpBackend.flush(); 47 | }); 48 | 49 | it('maintains object reference', function() { 50 | expect(TestCollection.items().length).toEqual(0); 51 | var item = TestCollection.item(1); 52 | item.refCheck = 123; 53 | expect(TestCollection.items().length).toEqual(0); 54 | TestCollection.getOne(item).then(function() { 55 | expect(item.refCheck).toEqual(123); 56 | expect(TestCollection.item(1).refCheck).toEqual(123); 57 | expect(TestCollection.items()[0].refCheck).toEqual(123); 58 | }); 59 | $httpBackend.flush(); 60 | }); 61 | 62 | it('creates a model object for pending items', function() { 63 | expect(TestCollection.items().length).toEqual(0); 64 | var item = TestCollection.item(1); 65 | expect(item.constructor.name).toEqual('TestModel'); 66 | }); 67 | 68 | it('returns the same instances object and array with deep similiarity', function() { 69 | var counter = 0; 70 | function TestModel2(data) { 71 | angular.extend(this, data); 72 | this.counter = counter++; 73 | }; 74 | TestCollection2 = new SmartCollection({ 75 | model: TestModel2, 76 | routes: { 77 | getOne: { 78 | method: 'get', 79 | responseType: 'one', 80 | url: '/test/:id' 81 | } 82 | } 83 | }); 84 | 85 | var obj = TestCollection2.item({id:1}); 86 | var items = TestCollection2.items(); 87 | TestCollection2.getOne(obj).then(function(newObj) { 88 | expect(TestCollection2.items()).toEqual(items); 89 | expect(newObj).toEqual(obj); 90 | }); 91 | $httpBackend.flush(); 92 | 93 | }) 94 | }); 95 | 96 | //////////////////////////////////////////////////////////////////////////////// 97 | // Piggybacks requests 98 | //////////////////////////////////////////////////////////////////////////////// 99 | describe('request piggybacking', function() { 100 | var $httpBackend; 101 | var SmartCollection; 102 | var TestCollection; 103 | var responseCounter, promiseCounter; 104 | 105 | beforeEach(module('SmartCollection')); 106 | beforeEach(inject(function(_$httpBackend_, _SmartCollection_) { 107 | SmartCollection = _SmartCollection_; 108 | $httpBackend = _$httpBackend_; 109 | 110 | responseCounter = 0; 111 | promiseCounter = 0; 112 | TestCollection = new SmartCollection({ 113 | routes: { 114 | getOne: { 115 | method: 'get', 116 | responseType: 'one', 117 | url: '/test/:id', 118 | transformResponseData: function(responseData, item) { 119 | responseCounter++; 120 | return item; 121 | } 122 | } 123 | } 124 | }); 125 | $httpBackend.when('GET', '/test/1').respond({id: 1, name:"one"}); 126 | $httpBackend.when('GET', '/test/2').respond({id: 2, name:"two"}); 127 | })) 128 | 129 | /////////////////////////////////////////////////////////////////////////////// 130 | 131 | it('reuses the same promise', function() { 132 | TestCollection.getOne(1).then(function() { promiseCounter++; }); 133 | TestCollection.getOne(1).then(function() { promiseCounter++; }); 134 | $httpBackend.flush(); 135 | 136 | expect(responseCounter).toEqual(1); 137 | expect(promiseCounter).toEqual(2); 138 | }); 139 | 140 | it('resets the promise cache after each request', function() { 141 | TestCollection.getOne(1).then(function() { promiseCounter++; }); 142 | $httpBackend.flush(); 143 | TestCollection.getOne(1).then(function() { promiseCounter++; }); 144 | $httpBackend.flush(); 145 | 146 | expect(responseCounter).toEqual(2); 147 | expect(promiseCounter).toEqual(2); 148 | }); 149 | 150 | it('returns a new promise for separate requests', function() { 151 | TestCollection.getOne(1).then(function() { promiseCounter++; }); 152 | TestCollection.getOne(2).then(function() { promiseCounter++; }); 153 | $httpBackend.flush(); 154 | 155 | expect(responseCounter).toEqual(2); 156 | expect(promiseCounter).toEqual(2); 157 | }); 158 | }); 159 | 160 | 161 | 162 | //////////////////////////////////////////////////////////////////////////////// 163 | // Transformed request and response 164 | //////////////////////////////////////////////////////////////////////////////// 165 | describe('transform request', function() { 166 | var $httpBackend; 167 | var SmartCollection; 168 | var TestCollection; 169 | function TestModel(data) { 170 | angular.extend(this, data); 171 | }; 172 | 173 | beforeEach(module('SmartCollection')); 174 | beforeEach(inject(function(_$httpBackend_, _SmartCollection_) { 175 | SmartCollection = _SmartCollection_; 176 | TestCollection = new SmartCollection({ 177 | model: TestModel, 178 | routes: { 179 | getOne: { 180 | method: 'get', 181 | responseType: 'one', 182 | url: '/test/:id', 183 | transformResponseData: function(responseData, item) { 184 | return {worked:true}; 185 | } 186 | } 187 | } 188 | }); 189 | 190 | $httpBackend = _$httpBackend_; 191 | $httpBackend.when('GET', '/test/1').respond({id: 1, name:"one"}); 192 | $httpBackend.when('GET', '/test/2').respond({id: 2, name:"two"}); 193 | })) 194 | 195 | /////////////////////////////////////////////////////////////////////////////// 196 | 197 | it('transforms the response', function() { 198 | TestCollection.getOne(1).then(function() { 199 | expect(TestCollection.items()[0].worked).toEqual(true); 200 | }); 201 | $httpBackend.flush(); 202 | }); 203 | }); 204 | 205 | 206 | //////////////////////////////////////////////////////////////////////////////// 207 | // Prefixed Responses 208 | //////////////////////////////////////////////////////////////////////////////// 209 | describe('one prefixed response', function() { 210 | var $httpBackend; 211 | var SmartCollection; 212 | var TestCollection; 213 | function TestModel(data) { 214 | angular.extend(this, data); 215 | }; 216 | 217 | beforeEach(module('SmartCollection')); 218 | beforeEach(inject(function(_$httpBackend_, _SmartCollection_) { 219 | SmartCollection = _SmartCollection_; 220 | TestCollection = new SmartCollection({ 221 | model: TestModel, 222 | routes: { 223 | getOne: { 224 | method: 'get', 225 | responsePrefix: 'prefix', 226 | responseType: 'one', 227 | url: '/test/:id' 228 | } 229 | } 230 | }); 231 | 232 | $httpBackend = _$httpBackend_; 233 | $httpBackend.when('GET', '/test/1').respond({prefix: {id: 1, name:"one"}}); 234 | $httpBackend.when('GET', '/test/2').respond({prefix: {id: 2, name:"two"}}); 235 | })) 236 | 237 | /////////////////////////////////////////////////////////////////////////////// 238 | 239 | it('parses prefixed responses', function() { 240 | TestCollection.getOne(1).then(function() { 241 | expect(TestCollection.item(1).name).toEqual('one'); 242 | }); 243 | $httpBackend.flush(); 244 | }); 245 | }); 246 | 247 | //////////////////////////////////////////////////////////////////////////////// 248 | // Complex Keys 249 | //////////////////////////////////////////////////////////////////////////////// 250 | describe('complex keys', function() { 251 | var $httpBackend; 252 | var SmartCollection; 253 | var TestCollection; 254 | function TestModel(data) { 255 | angular.extend(this, data); 256 | }; 257 | 258 | beforeEach(module('SmartCollection')); 259 | beforeEach(inject(function(_$httpBackend_, _SmartCollection_) { 260 | SmartCollection = _SmartCollection_; 261 | TestCollection = new SmartCollection({ 262 | model: TestModel, 263 | key: ['number', 'letter'], 264 | routes: { 265 | getAll: { 266 | method: 'get', 267 | responseType: 'array', 268 | url: '/test' 269 | }, 270 | getOne: { 271 | method: 'get', 272 | responseType: 'one', 273 | url: '/test/:number/:letter' 274 | }, 275 | removeOne: { 276 | method: 'delete', 277 | responseType: 'remove', 278 | url: '/test/:number/:letter' 279 | } 280 | } 281 | }); 282 | 283 | var values = [ 284 | {number: 1, letter:"A", secret:"W"}, 285 | {number: 1, letter:"B", secret:"X"}, 286 | {number: 2, letter:"A", secret:"Y"}, 287 | {number: 2, letter:"B", secret:"Z"} 288 | ]; 289 | 290 | $httpBackend = _$httpBackend_; 291 | $httpBackend.when('GET', '/test').respond(values); 292 | $httpBackend.when('GET', '/test/1/A').respond(values[0]); 293 | $httpBackend.when('GET', '/test/1/B').respond(values[1]); 294 | $httpBackend.when('GET', '/test/2/A').respond(values[2]); 295 | $httpBackend.when('GET', '/test/2/B').respond(values[3]); 296 | $httpBackend.when('DELETE', '/test/1/B').respond({success:true}); 297 | })) 298 | 299 | /////////////////////////////////////////////////////////////////////////////// 300 | 301 | it('parses prefixed responses', function() { 302 | var obj = {number:1, letter:'B'}; 303 | TestCollection.getOne(obj).then(function() { 304 | expect(TestCollection.lookup(obj).secret).toEqual('X'); 305 | }); 306 | $httpBackend.flush(); 307 | }); 308 | 309 | it('gets all items properly', function() { 310 | var items = TestCollection.items(); 311 | TestCollection.getAll().then(function() { expect(items.length).toEqual(4); }); 312 | $httpBackend.flush(); 313 | 314 | TestCollection.removeOne({number:1, letter:'B'}).then(function() { expect(items.length).toEqual(3); }); 315 | $httpBackend.flush(); 316 | }); 317 | 318 | it('handles complex keys with pending items', function() { 319 | var item = TestCollection.item({number:1, letter:'A'}); 320 | 321 | expect(item.secret).toBeUndefined(); 322 | TestCollection.getOne({number:1, letter:'A'}).then(function() { 323 | expect(item.secret).toEqual('W'); 324 | }); 325 | $httpBackend.flush(); 326 | }) 327 | 328 | it('handles multiple pending lookups', function() { 329 | var item1 = TestCollection.item({number:1, letter:'A'}); 330 | var item2 = TestCollection.item({number:1, letter:'A'}); 331 | expect(item1).toEqual(item2); 332 | }) 333 | }); 334 | 335 | }) 336 | --------------------------------------------------------------------------------