├── .gitignore ├── index.html ├── js ├── app.js ├── controllers │ └── todoCtrl.js ├── directives │ ├── todoEscape.js │ └── todoFocus.js └── services │ └── todoStorage.js ├── node_modules ├── angular-resource │ └── angular-resource.js ├── angular-route │ └── angular-route.js ├── angular │ └── angular.js ├── todomvc-app-css │ └── index.css └── todomvc-common │ ├── base.css │ └── base.js ├── package.json ├── readme.md └── test ├── config └── karma.conf.js └── unit ├── directivesSpec.js └── todoCtrlSpec.js /.gitignore: -------------------------------------------------------------------------------- 1 | test/ -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | To Do List 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /js/app.js: -------------------------------------------------------------------------------- 1 | /*global angular */ 2 | 3 | /** 4 | * The main TodoMVC app module 5 | * 6 | * @type {angular.Module} 7 | */ 8 | angular.module('todomvc', ['ngRoute', 'ngResource']) 9 | .config(function ($routeProvider) { 10 | 'use strict'; 11 | 12 | var routeConfig = { 13 | controller: 'TodoCtrl', 14 | templateUrl: 'todomvc-index.html', 15 | resolve: { 16 | store: function (todoStorage) { 17 | // Get the correct module (API or localStorage). 18 | return todoStorage.then(function (module) { 19 | module.get(); // Fetch the todo records in the background. 20 | return module; 21 | }); 22 | } 23 | } 24 | }; 25 | 26 | $routeProvider 27 | .when('/', routeConfig) 28 | .when('/:status', routeConfig) 29 | .otherwise({ 30 | redirectTo: '/' 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /js/controllers/todoCtrl.js: -------------------------------------------------------------------------------- 1 | /*global angular */ 2 | 3 | /** 4 | * The main controller for the app. The controller: 5 | * - retrieves and persists the model via the todoStorage service 6 | * - exposes the model to the template and provides event handlers 7 | */ 8 | angular.module('todomvc') 9 | .controller('TodoCtrl', function TodoCtrl($scope, $routeParams, $filter, store) { 10 | 'use strict'; 11 | 12 | var todos = $scope.todos = store.todos; 13 | 14 | $scope.newTodo = ''; 15 | $scope.editedTodo = null; 16 | 17 | $scope.$watch('todos', function () { 18 | $scope.remainingCount = $filter('filter')(todos, { completed: false }).length -1; 19 | $scope.completedCount = todos.length - $scope.remainingCount; 20 | $scope.allChecked = !$scope.remainingCount; 21 | }, true); 22 | 23 | // Monitor the current route for changes and adjust the filter accordingly. 24 | $scope.$on('$routeChangeSuccess', function () { 25 | var status = $scope.status = $routeParams.status || ''; 26 | $scope.statusFilter = (status === 'active') ? 27 | { completed: false } : (status === 'completed') ? 28 | { completed: true } : {}; 29 | }); 30 | 31 | $scope.addTodo = function () { 32 | var newTodo = { 33 | title: $scope.newTodo.trim(), 34 | completed: false 35 | }; 36 | 37 | if (!newTodo.title) { 38 | return; 39 | } 40 | 41 | $scope.saving = true; 42 | store.insert(newTodo) 43 | .then(function success() { 44 | $scope.newTodo = ' '; 45 | }) 46 | .finally(function () { 47 | $scope.saving = false; 48 | }); 49 | }; 50 | 51 | $scope.editTodo = function (todo) { 52 | $scope.editedTodo = todo; 53 | // Clone the original todo to restore it on demand. 54 | $scope.originalTodo = angular.extend({}, todo); 55 | }; 56 | 57 | $scope.saveEdits = function (todo, event) { 58 | // Blur events are automatically triggered after the form submit event. 59 | // This does some unfortunate logic handling to prevent saving twice. 60 | if (event === 'blur' && $scope.saveEvent === 'submit') { 61 | $scope.saveEvent = null; 62 | return; 63 | } 64 | 65 | $scope.saveEvent = event; 66 | 67 | if ($scope.reverted) { 68 | // Todo edits were reverted-- don't save. 69 | $scope.reverted = null; 70 | return; 71 | } 72 | 73 | todo.title = todo.title.trim(); 74 | 75 | if (todo.title === $scope.originalTodo.title) { 76 | $scope.editedTodo = null; 77 | return; 78 | } 79 | 80 | store[todo.title ? 'put' : 'delete'](todo) 81 | .then(function success() {}, function error() { 82 | todo.title = $scope.originalTodo.title; 83 | }) 84 | .finally(function () { 85 | $scope.editedTodo = null; 86 | }); 87 | }; 88 | 89 | $scope.revertEdits = function (todo) { 90 | todos[todos.indexOf(todo)] = $scope.originalTodo; 91 | $scope.editedTodo = null; 92 | $scope.originalTodo = null; 93 | $scope.reverted = true; 94 | }; 95 | 96 | $scope.removeTodo = function (todo) { 97 | store.delete(todo); 98 | }; 99 | 100 | $scope.saveTodo = function (todo) { 101 | store.put(todo); 102 | }; 103 | 104 | $scope.toggleCompleted = function (todo, completed) { 105 | if (angular.isDefined(completed)) { 106 | todo.completed = completed; 107 | } 108 | store.put(todo, todos.indexOf(todo)) 109 | .then(function success() {}, function error() { 110 | todo.completed = !todo.completed; 111 | }); 112 | }; 113 | 114 | $scope.clearCompletedTodos = function () { 115 | store.clearCompleted(); 116 | }; 117 | 118 | $scope.markAll = function (completed) { 119 | todos.forEach(function (todo) { 120 | //if (todo.completed !== completed) { 121 | $scope.toggleCompleted(todo, completed); 122 | //} 123 | }); 124 | }; 125 | }); 126 | -------------------------------------------------------------------------------- /js/directives/todoEscape.js: -------------------------------------------------------------------------------- 1 | /*global angular */ 2 | 3 | /** 4 | * Directive that executes an expression when the element it is applied to gets 5 | * an `escape` keydown event. 6 | */ 7 | angular.module('todomvc') 8 | .directive('todoEscape', function () { 9 | 'use strict'; 10 | 11 | var ESCAPE_KEY = 27; 12 | 13 | return function (scope, elem, attrs) { 14 | elem.bind('keydown', function (event) { 15 | if (event.keyCode === ESCAPE_KEY) { 16 | scope.$apply(attrs.todoEscape); 17 | } 18 | }); 19 | 20 | scope.$on('$destroy', function () { 21 | elem.unbind('keydown'); 22 | }); 23 | }; 24 | }); 25 | -------------------------------------------------------------------------------- /js/directives/todoFocus.js: -------------------------------------------------------------------------------- 1 | /*global angular */ 2 | 3 | /** 4 | * Directive that places focus on the element it is applied to when the 5 | * expression it binds to evaluates to true 6 | */ 7 | angular.module('todomvc') 8 | .directive('todoFocus', function todoFocus($timeout) { 9 | 'use strict'; 10 | 11 | return function (scope, elem, attrs) { 12 | scope.$watch(attrs.todoFocus, function (newVal) { 13 | if (newVal) { 14 | $timeout(function () { 15 | elem[0].focus(); 16 | }, 0, false); 17 | } 18 | }); 19 | }; 20 | }); 21 | -------------------------------------------------------------------------------- /js/services/todoStorage.js: -------------------------------------------------------------------------------- 1 | /*global angular */ 2 | 3 | /** 4 | * Services that persists and retrieves todos from localStorage or a backend API 5 | * if available. 6 | * 7 | * They both follow the same API, returning promises for all changes to the 8 | * model. 9 | */ 10 | angular.module('todomvc') 11 | .factory('todoStorage', function ($http, $injector) { 12 | 'use strict'; 13 | 14 | // Detect if an API backend is present. If so, return the API module, else 15 | // hand off the localStorage adapter 16 | return $http.get('/api') 17 | .then(function () { 18 | return $injector.get('api'); 19 | }, function () { 20 | return $injector.get('localStorage'); 21 | }); 22 | }) 23 | 24 | .factory('api', function ($resource) { 25 | 'use strict'; 26 | 27 | var store = { 28 | todos: [], 29 | 30 | api: $resource('/api/todos/:id', null, 31 | { 32 | update: { method:'PUT' } 33 | } 34 | ), 35 | 36 | clearCompleted: function () { 37 | var originalTodos = store.todos.slice(0); 38 | 39 | var incompleteTodos = store.todos.filter(function (todo) { 40 | return !todo.completed; 41 | }); 42 | 43 | angular.copy(incompleteTodos, store.todos); 44 | 45 | return store.api.delete(function () { 46 | }, function error() { 47 | angular.copy(originalTodos, store.todos); 48 | }); 49 | }, 50 | 51 | delete: function (todo) { 52 | var originalTodos = store.todos.slice(0); 53 | 54 | store.todos.splice(store.todos.indexOf(todo), 1); 55 | return store.api.delete({ id: todo.id }, 56 | function () { 57 | }, function error() { 58 | angular.copy(originalTodos, store.todos); 59 | }); 60 | }, 61 | 62 | get: function () { 63 | return store.api.query(function (resp) { 64 | angular.copy(resp, store.todos); 65 | }); 66 | }, 67 | 68 | insert: function (todo) { 69 | var originalTodos = store.todos.slice(0); 70 | 71 | return store.api.save(todo, 72 | function success(resp) { 73 | todo.id = resp.id; 74 | store.todos.push(todo); 75 | }, function error() { 76 | angular.copy(originalTodos, store.todos); 77 | }) 78 | .$promise; 79 | }, 80 | 81 | put: function (todo) { 82 | return store.api.update({ id: todo.id }, todo) 83 | .$promise; 84 | } 85 | }; 86 | 87 | return store; 88 | }) 89 | 90 | .factory('localStorage', function ($q) { 91 | 'use strict'; 92 | 93 | var STORAGE_ID = 'todos-angularjs'; 94 | 95 | var store = { 96 | todos: [], 97 | 98 | _getFromLocalStorage: function () { 99 | return JSON.parse(localStorage.getItem(STORAGE_ID) || '[]'); 100 | }, 101 | 102 | _saveToLocalStorage: function (todos) { 103 | localStorage.setItem(STORAGE_ID, JSON.stringify(todos)); 104 | }, 105 | 106 | clearCompleted: function () { 107 | var deferred = $q.defer(); 108 | 109 | var incompleteTodos = store.todos.filter(function (todo) { 110 | return !todo.completed; 111 | }); 112 | 113 | angular.copy(incompleteTodos, store.todos); 114 | 115 | store._saveToLocalStorage(store.todos); 116 | deferred.resolve(store.todos); 117 | 118 | return deferred.promise; 119 | }, 120 | 121 | delete: function (todo) { 122 | var deferred = $q.defer(); 123 | 124 | store.todos.splice(store.todos.indexOf(todo), 1); 125 | 126 | store._saveToLocalStorage(store.todos); 127 | deferred.resolve(store.todos); 128 | 129 | return deferred.promise; 130 | }, 131 | 132 | get: function () { 133 | var deferred = $q.defer(); 134 | 135 | angular.copy(store._getFromLocalStorage(), store.todos); 136 | deferred.resolve(store.todos); 137 | 138 | return deferred.promise; 139 | }, 140 | 141 | insert: function (todo) { 142 | var deferred = $q.defer(); 143 | 144 | store.todos.push(todo); 145 | 146 | store._saveToLocalStorage(store.todos); 147 | deferred.resolve(store.todos); 148 | 149 | return deferred.promise; 150 | }, 151 | 152 | put: function (todo, index) { 153 | var deferred = $q.defer(); 154 | 155 | store.todos[index] = todo; 156 | 157 | store._saveToLocalStorage(store.todos); 158 | deferred.resolve(store.todos); 159 | 160 | return deferred.promise; 161 | } 162 | }; 163 | 164 | return store; 165 | }); 166 | -------------------------------------------------------------------------------- /node_modules/angular-resource/angular-resource.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license AngularJS v1.4.3 3 | * (c) 2010-2015 Google, Inc. http://angularjs.org 4 | * License: MIT 5 | */ 6 | (function(window, angular, undefined) {'use strict'; 7 | 8 | var $resourceMinErr = angular.$$minErr('$resource'); 9 | 10 | // Helper functions and regex to lookup a dotted path on an object 11 | // stopping at undefined/null. The path must be composed of ASCII 12 | // identifiers (just like $parse) 13 | var MEMBER_NAME_REGEX = /^(\.[a-zA-Z_$@][0-9a-zA-Z_$@]*)+$/; 14 | 15 | function isValidDottedPath(path) { 16 | return (path != null && path !== '' && path !== 'hasOwnProperty' && 17 | MEMBER_NAME_REGEX.test('.' + path)); 18 | } 19 | 20 | function lookupDottedPath(obj, path) { 21 | if (!isValidDottedPath(path)) { 22 | throw $resourceMinErr('badmember', 'Dotted member path "@{0}" is invalid.', path); 23 | } 24 | var keys = path.split('.'); 25 | for (var i = 0, ii = keys.length; i < ii && obj !== undefined; i++) { 26 | var key = keys[i]; 27 | obj = (obj !== null) ? obj[key] : undefined; 28 | } 29 | return obj; 30 | } 31 | 32 | /** 33 | * Create a shallow copy of an object and clear other fields from the destination 34 | */ 35 | function shallowClearAndCopy(src, dst) { 36 | dst = dst || {}; 37 | 38 | angular.forEach(dst, function(value, key) { 39 | delete dst[key]; 40 | }); 41 | 42 | for (var key in src) { 43 | if (src.hasOwnProperty(key) && !(key.charAt(0) === '$' && key.charAt(1) === '$')) { 44 | dst[key] = src[key]; 45 | } 46 | } 47 | 48 | return dst; 49 | } 50 | 51 | /** 52 | * @ngdoc module 53 | * @name ngResource 54 | * @description 55 | * 56 | * # ngResource 57 | * 58 | * The `ngResource` module provides interaction support with RESTful services 59 | * via the $resource service. 60 | * 61 | * 62 | *
63 | * 64 | * See {@link ngResource.$resource `$resource`} for usage. 65 | */ 66 | 67 | /** 68 | * @ngdoc service 69 | * @name $resource 70 | * @requires $http 71 | * 72 | * @description 73 | * A factory which creates a resource object that lets you interact with 74 | * [RESTful](http://en.wikipedia.org/wiki/Representational_State_Transfer) server-side data sources. 75 | * 76 | * The returned resource object has action methods which provide high-level behaviors without 77 | * the need to interact with the low level {@link ng.$http $http} service. 78 | * 79 | * Requires the {@link ngResource `ngResource`} module to be installed. 80 | * 81 | * By default, trailing slashes will be stripped from the calculated URLs, 82 | * which can pose problems with server backends that do not expect that 83 | * behavior. This can be disabled by configuring the `$resourceProvider` like 84 | * this: 85 | * 86 | * ```js 87 | app.config(['$resourceProvider', function($resourceProvider) { 88 | // Don't strip trailing slashes from calculated URLs 89 | $resourceProvider.defaults.stripTrailingSlashes = false; 90 | }]); 91 | * ``` 92 | * 93 | * @param {string} url A parameterized URL template with parameters prefixed by `:` as in 94 | * `/user/:username`. If you are using a URL with a port number (e.g. 95 | * `http://example.com:8080/api`), it will be respected. 96 | * 97 | * If you are using a url with a suffix, just add the suffix, like this: 98 | * `$resource('http://example.com/resource.json')` or `$resource('http://example.com/:id.json')` 99 | * or even `$resource('http://example.com/resource/:resource_id.:format')` 100 | * If the parameter before the suffix is empty, :resource_id in this case, then the `/.` will be 101 | * collapsed down to a single `.`. If you need this sequence to appear and not collapse then you 102 | * can escape it with `/\.`. 103 | * 104 | * @param {Object=} paramDefaults Default values for `url` parameters. These can be overridden in 105 | * `actions` methods. If any of the parameter value is a function, it will be executed every time 106 | * when a param value needs to be obtained for a request (unless the param was overridden). 107 | * 108 | * Each key value in the parameter object is first bound to url template if present and then any 109 | * excess keys are appended to the url search query after the `?`. 110 | * 111 | * Given a template `/path/:verb` and parameter `{verb:'greet', salutation:'Hello'}` results in 112 | * URL `/path/greet?salutation=Hello`. 113 | * 114 | * If the parameter value is prefixed with `@` then the value for that parameter will be extracted 115 | * from the corresponding property on the `data` object (provided when calling an action method). For 116 | * example, if the `defaultParam` object is `{someParam: '@someProp'}` then the value of `someParam` 117 | * will be `data.someProp`. 118 | * 119 | * @param {Object.=} actions Hash with declaration of custom actions that should extend 120 | * the default set of resource actions. The declaration should be created in the format of {@link 121 | * ng.$http#usage $http.config}: 122 | * 123 | * {action1: {method:?, params:?, isArray:?, headers:?, ...}, 124 | * action2: {method:?, params:?, isArray:?, headers:?, ...}, 125 | * ...} 126 | * 127 | * Where: 128 | * 129 | * - **`action`** – {string} – The name of action. This name becomes the name of the method on 130 | * your resource object. 131 | * - **`method`** – {string} – Case insensitive HTTP method (e.g. `GET`, `POST`, `PUT`, 132 | * `DELETE`, `JSONP`, etc). 133 | * - **`params`** – {Object=} – Optional set of pre-bound parameters for this action. If any of 134 | * the parameter value is a function, it will be executed every time when a param value needs to 135 | * be obtained for a request (unless the param was overridden). 136 | * - **`url`** – {string} – action specific `url` override. The url templating is supported just 137 | * like for the resource-level urls. 138 | * - **`isArray`** – {boolean=} – If true then the returned object for this action is an array, 139 | * see `returns` section. 140 | * - **`transformRequest`** – 141 | * `{function(data, headersGetter)|Array.}` – 142 | * transform function or an array of such functions. The transform function takes the http 143 | * request body and headers and returns its transformed (typically serialized) version. 144 | * By default, transformRequest will contain one function that checks if the request data is 145 | * an object and serializes to using `angular.toJson`. To prevent this behavior, set 146 | * `transformRequest` to an empty array: `transformRequest: []` 147 | * - **`transformResponse`** – 148 | * `{function(data, headersGetter)|Array.}` – 149 | * transform function or an array of such functions. The transform function takes the http 150 | * response body and headers and returns its transformed (typically deserialized) version. 151 | * By default, transformResponse will contain one function that checks if the response looks like 152 | * a JSON string and deserializes it using `angular.fromJson`. To prevent this behavior, set 153 | * `transformResponse` to an empty array: `transformResponse: []` 154 | * - **`cache`** – `{boolean|Cache}` – If true, a default $http cache will be used to cache the 155 | * GET request, otherwise if a cache instance built with 156 | * {@link ng.$cacheFactory $cacheFactory}, this cache will be used for 157 | * caching. 158 | * - **`timeout`** – `{number|Promise}` – timeout in milliseconds, or {@link ng.$q promise} that 159 | * should abort the request when resolved. 160 | * - **`withCredentials`** - `{boolean}` - whether to set the `withCredentials` flag on the 161 | * XHR object. See 162 | * [requests with credentials](https://developer.mozilla.org/en/http_access_control#section_5) 163 | * for more information. 164 | * - **`responseType`** - `{string}` - see 165 | * [requestType](https://developer.mozilla.org/en-US/docs/DOM/XMLHttpRequest#responseType). 166 | * - **`interceptor`** - `{Object=}` - The interceptor object has two optional methods - 167 | * `response` and `responseError`. Both `response` and `responseError` interceptors get called 168 | * with `http response` object. See {@link ng.$http $http interceptors}. 169 | * 170 | * @param {Object} options Hash with custom settings that should extend the 171 | * default `$resourceProvider` behavior. The only supported option is 172 | * 173 | * Where: 174 | * 175 | * - **`stripTrailingSlashes`** – {boolean} – If true then the trailing 176 | * slashes from any calculated URL will be stripped. (Defaults to true.) 177 | * 178 | * @returns {Object} A resource "class" object with methods for the default set of resource actions 179 | * optionally extended with custom `actions`. The default set contains these actions: 180 | * ```js 181 | * { 'get': {method:'GET'}, 182 | * 'save': {method:'POST'}, 183 | * 'query': {method:'GET', isArray:true}, 184 | * 'remove': {method:'DELETE'}, 185 | * 'delete': {method:'DELETE'} }; 186 | * ``` 187 | * 188 | * Calling these methods invoke an {@link ng.$http} with the specified http method, 189 | * destination and parameters. When the data is returned from the server then the object is an 190 | * instance of the resource class. The actions `save`, `remove` and `delete` are available on it 191 | * as methods with the `$` prefix. This allows you to easily perform CRUD operations (create, 192 | * read, update, delete) on server-side data like this: 193 | * ```js 194 | * var User = $resource('/user/:userId', {userId:'@id'}); 195 | * var user = User.get({userId:123}, function() { 196 | * user.abc = true; 197 | * user.$save(); 198 | * }); 199 | * ``` 200 | * 201 | * It is important to realize that invoking a $resource object method immediately returns an 202 | * empty reference (object or array depending on `isArray`). Once the data is returned from the 203 | * server the existing reference is populated with the actual data. This is a useful trick since 204 | * usually the resource is assigned to a model which is then rendered by the view. Having an empty 205 | * object results in no rendering, once the data arrives from the server then the object is 206 | * populated with the data and the view automatically re-renders itself showing the new data. This 207 | * means that in most cases one never has to write a callback function for the action methods. 208 | * 209 | * The action methods on the class object or instance object can be invoked with the following 210 | * parameters: 211 | * 212 | * - HTTP GET "class" actions: `Resource.action([parameters], [success], [error])` 213 | * - non-GET "class" actions: `Resource.action([parameters], postData, [success], [error])` 214 | * - non-GET instance actions: `instance.$action([parameters], [success], [error])` 215 | * 216 | * 217 | * Success callback is called with (value, responseHeaders) arguments, where the value is 218 | * the populated resource instance or collection object. The error callback is called 219 | * with (httpResponse) argument. 220 | * 221 | * Class actions return empty instance (with additional properties below). 222 | * Instance actions return promise of the action. 223 | * 224 | * The Resource instances and collection have these additional properties: 225 | * 226 | * - `$promise`: the {@link ng.$q promise} of the original server interaction that created this 227 | * instance or collection. 228 | * 229 | * On success, the promise is resolved with the same resource instance or collection object, 230 | * updated with data from server. This makes it easy to use in 231 | * {@link ngRoute.$routeProvider resolve section of $routeProvider.when()} to defer view 232 | * rendering until the resource(s) are loaded. 233 | * 234 | * On failure, the promise is resolved with the {@link ng.$http http response} object, without 235 | * the `resource` property. 236 | * 237 | * If an interceptor object was provided, the promise will instead be resolved with the value 238 | * returned by the interceptor. 239 | * 240 | * - `$resolved`: `true` after first server interaction is completed (either with success or 241 | * rejection), `false` before that. Knowing if the Resource has been resolved is useful in 242 | * data-binding. 243 | * 244 | * @example 245 | * 246 | * # Credit card resource 247 | * 248 | * ```js 249 | // Define CreditCard class 250 | var CreditCard = $resource('/user/:userId/card/:cardId', 251 | {userId:123, cardId:'@id'}, { 252 | charge: {method:'POST', params:{charge:true}} 253 | }); 254 | 255 | // We can retrieve a collection from the server 256 | var cards = CreditCard.query(function() { 257 | // GET: /user/123/card 258 | // server returns: [ {id:456, number:'1234', name:'Smith'} ]; 259 | 260 | var card = cards[0]; 261 | // each item is an instance of CreditCard 262 | expect(card instanceof CreditCard).toEqual(true); 263 | card.name = "J. Smith"; 264 | // non GET methods are mapped onto the instances 265 | card.$save(); 266 | // POST: /user/123/card/456 {id:456, number:'1234', name:'J. Smith'} 267 | // server returns: {id:456, number:'1234', name: 'J. Smith'}; 268 | 269 | // our custom method is mapped as well. 270 | card.$charge({amount:9.99}); 271 | // POST: /user/123/card/456?amount=9.99&charge=true {id:456, number:'1234', name:'J. Smith'} 272 | }); 273 | 274 | // we can create an instance as well 275 | var newCard = new CreditCard({number:'0123'}); 276 | newCard.name = "Mike Smith"; 277 | newCard.$save(); 278 | // POST: /user/123/card {number:'0123', name:'Mike Smith'} 279 | // server returns: {id:789, number:'0123', name: 'Mike Smith'}; 280 | expect(newCard.id).toEqual(789); 281 | * ``` 282 | * 283 | * The object returned from this function execution is a resource "class" which has "static" method 284 | * for each action in the definition. 285 | * 286 | * Calling these methods invoke `$http` on the `url` template with the given `method`, `params` and 287 | * `headers`. 288 | * When the data is returned from the server then the object is an instance of the resource type and 289 | * all of the non-GET methods are available with `$` prefix. This allows you to easily support CRUD 290 | * operations (create, read, update, delete) on server-side data. 291 | 292 | ```js 293 | var User = $resource('/user/:userId', {userId:'@id'}); 294 | User.get({userId:123}, function(user) { 295 | user.abc = true; 296 | user.$save(); 297 | }); 298 | ``` 299 | * 300 | * It's worth noting that the success callback for `get`, `query` and other methods gets passed 301 | * in the response that came from the server as well as $http header getter function, so one 302 | * could rewrite the above example and get access to http headers as: 303 | * 304 | ```js 305 | var User = $resource('/user/:userId', {userId:'@id'}); 306 | User.get({userId:123}, function(u, getResponseHeaders){ 307 | u.abc = true; 308 | u.$save(function(u, putResponseHeaders) { 309 | //u => saved user object 310 | //putResponseHeaders => $http header getter 311 | }); 312 | }); 313 | ``` 314 | * 315 | * You can also access the raw `$http` promise via the `$promise` property on the object returned 316 | * 317 | ``` 318 | var User = $resource('/user/:userId', {userId:'@id'}); 319 | User.get({userId:123}) 320 | .$promise.then(function(user) { 321 | $scope.user = user; 322 | }); 323 | ``` 324 | 325 | * # Creating a custom 'PUT' request 326 | * In this example we create a custom method on our resource to make a PUT request 327 | * ```js 328 | * var app = angular.module('app', ['ngResource', 'ngRoute']); 329 | * 330 | * // Some APIs expect a PUT request in the format URL/object/ID 331 | * // Here we are creating an 'update' method 332 | * app.factory('Notes', ['$resource', function($resource) { 333 | * return $resource('/notes/:id', null, 334 | * { 335 | * 'update': { method:'PUT' } 336 | * }); 337 | * }]); 338 | * 339 | * // In our controller we get the ID from the URL using ngRoute and $routeParams 340 | * // We pass in $routeParams and our Notes factory along with $scope 341 | * app.controller('NotesCtrl', ['$scope', '$routeParams', 'Notes', 342 | function($scope, $routeParams, Notes) { 343 | * // First get a note object from the factory 344 | * var note = Notes.get({ id:$routeParams.id }); 345 | * $id = note.id; 346 | * 347 | * // Now call update passing in the ID first then the object you are updating 348 | * Notes.update({ id:$id }, note); 349 | * 350 | * // This will PUT /notes/ID with the note object in the request payload 351 | * }]); 352 | * ``` 353 | */ 354 | angular.module('ngResource', ['ng']). 355 | provider('$resource', function() { 356 | var provider = this; 357 | 358 | this.defaults = { 359 | // Strip slashes by default 360 | stripTrailingSlashes: true, 361 | 362 | // Default actions configuration 363 | actions: { 364 | 'get': {method: 'GET'}, 365 | 'save': {method: 'POST'}, 366 | 'query': {method: 'GET', isArray: true}, 367 | 'remove': {method: 'DELETE'}, 368 | 'delete': {method: 'DELETE'} 369 | } 370 | }; 371 | 372 | this.$get = ['$http', '$q', function($http, $q) { 373 | 374 | var noop = angular.noop, 375 | forEach = angular.forEach, 376 | extend = angular.extend, 377 | copy = angular.copy, 378 | isFunction = angular.isFunction; 379 | 380 | /** 381 | * We need our custom method because encodeURIComponent is too aggressive and doesn't follow 382 | * http://www.ietf.org/rfc/rfc3986.txt with regards to the character set 383 | * (pchar) allowed in path segments: 384 | * segment = *pchar 385 | * pchar = unreserved / pct-encoded / sub-delims / ":" / "@" 386 | * pct-encoded = "%" HEXDIG HEXDIG 387 | * unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" 388 | * sub-delims = "!" / "$" / "&" / "'" / "(" / ")" 389 | * / "*" / "+" / "," / ";" / "=" 390 | */ 391 | function encodeUriSegment(val) { 392 | return encodeUriQuery(val, true). 393 | replace(/%26/gi, '&'). 394 | replace(/%3D/gi, '='). 395 | replace(/%2B/gi, '+'); 396 | } 397 | 398 | 399 | /** 400 | * This method is intended for encoding *key* or *value* parts of query component. We need a 401 | * custom method because encodeURIComponent is too aggressive and encodes stuff that doesn't 402 | * have to be encoded per http://tools.ietf.org/html/rfc3986: 403 | * query = *( pchar / "/" / "?" ) 404 | * pchar = unreserved / pct-encoded / sub-delims / ":" / "@" 405 | * unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" 406 | * pct-encoded = "%" HEXDIG HEXDIG 407 | * sub-delims = "!" / "$" / "&" / "'" / "(" / ")" 408 | * / "*" / "+" / "," / ";" / "=" 409 | */ 410 | function encodeUriQuery(val, pctEncodeSpaces) { 411 | return encodeURIComponent(val). 412 | replace(/%40/gi, '@'). 413 | replace(/%3A/gi, ':'). 414 | replace(/%24/g, '$'). 415 | replace(/%2C/gi, ','). 416 | replace(/%20/g, (pctEncodeSpaces ? '%20' : '+')); 417 | } 418 | 419 | function Route(template, defaults) { 420 | this.template = template; 421 | this.defaults = extend({}, provider.defaults, defaults); 422 | this.urlParams = {}; 423 | } 424 | 425 | Route.prototype = { 426 | setUrlParams: function(config, params, actionUrl) { 427 | var self = this, 428 | url = actionUrl || self.template, 429 | val, 430 | encodedVal; 431 | 432 | var urlParams = self.urlParams = {}; 433 | forEach(url.split(/\W/), function(param) { 434 | if (param === 'hasOwnProperty') { 435 | throw $resourceMinErr('badname', "hasOwnProperty is not a valid parameter name."); 436 | } 437 | if (!(new RegExp("^\\d+$").test(param)) && param && 438 | (new RegExp("(^|[^\\\\]):" + param + "(\\W|$)").test(url))) { 439 | urlParams[param] = true; 440 | } 441 | }); 442 | url = url.replace(/\\:/g, ':'); 443 | 444 | params = params || {}; 445 | forEach(self.urlParams, function(_, urlParam) { 446 | val = params.hasOwnProperty(urlParam) ? params[urlParam] : self.defaults[urlParam]; 447 | if (angular.isDefined(val) && val !== null) { 448 | encodedVal = encodeUriSegment(val); 449 | url = url.replace(new RegExp(":" + urlParam + "(\\W|$)", "g"), function(match, p1) { 450 | return encodedVal + p1; 451 | }); 452 | } else { 453 | url = url.replace(new RegExp("(\/?):" + urlParam + "(\\W|$)", "g"), function(match, 454 | leadingSlashes, tail) { 455 | if (tail.charAt(0) == '/') { 456 | return tail; 457 | } else { 458 | return leadingSlashes + tail; 459 | } 460 | }); 461 | } 462 | }); 463 | 464 | // strip trailing slashes and set the url (unless this behavior is specifically disabled) 465 | if (self.defaults.stripTrailingSlashes) { 466 | url = url.replace(/\/+$/, '') || '/'; 467 | } 468 | 469 | // then replace collapse `/.` if found in the last URL path segment before the query 470 | // E.g. `http://url.com/id./format?q=x` becomes `http://url.com/id.format?q=x` 471 | url = url.replace(/\/\.(?=\w+($|\?))/, '.'); 472 | // replace escaped `/\.` with `/.` 473 | config.url = url.replace(/\/\\\./, '/.'); 474 | 475 | 476 | // set params - delegate param encoding to $http 477 | forEach(params, function(value, key) { 478 | if (!self.urlParams[key]) { 479 | config.params = config.params || {}; 480 | config.params[key] = value; 481 | } 482 | }); 483 | } 484 | }; 485 | 486 | 487 | function resourceFactory(url, paramDefaults, actions, options) { 488 | var route = new Route(url, options); 489 | 490 | actions = extend({}, provider.defaults.actions, actions); 491 | 492 | function extractParams(data, actionParams) { 493 | var ids = {}; 494 | actionParams = extend({}, paramDefaults, actionParams); 495 | forEach(actionParams, function(value, key) { 496 | if (isFunction(value)) { value = value(); } 497 | ids[key] = value && value.charAt && value.charAt(0) == '@' ? 498 | lookupDottedPath(data, value.substr(1)) : value; 499 | }); 500 | return ids; 501 | } 502 | 503 | function defaultResponseInterceptor(response) { 504 | return response.resource; 505 | } 506 | 507 | function Resource(value) { 508 | shallowClearAndCopy(value || {}, this); 509 | } 510 | 511 | Resource.prototype.toJSON = function() { 512 | var data = extend({}, this); 513 | delete data.$promise; 514 | delete data.$resolved; 515 | return data; 516 | }; 517 | 518 | forEach(actions, function(action, name) { 519 | var hasBody = /^(POST|PUT|PATCH)$/i.test(action.method); 520 | 521 | Resource[name] = function(a1, a2, a3, a4) { 522 | var params = {}, data, success, error; 523 | 524 | /* jshint -W086 */ /* (purposefully fall through case statements) */ 525 | switch (arguments.length) { 526 | case 4: 527 | error = a4; 528 | success = a3; 529 | //fallthrough 530 | case 3: 531 | case 2: 532 | if (isFunction(a2)) { 533 | if (isFunction(a1)) { 534 | success = a1; 535 | error = a2; 536 | break; 537 | } 538 | 539 | success = a2; 540 | error = a3; 541 | //fallthrough 542 | } else { 543 | params = a1; 544 | data = a2; 545 | success = a3; 546 | break; 547 | } 548 | case 1: 549 | if (isFunction(a1)) success = a1; 550 | else if (hasBody) data = a1; 551 | else params = a1; 552 | break; 553 | case 0: break; 554 | default: 555 | throw $resourceMinErr('badargs', 556 | "Expected up to 4 arguments [params, data, success, error], got {0} arguments", 557 | arguments.length); 558 | } 559 | /* jshint +W086 */ /* (purposefully fall through case statements) */ 560 | 561 | var isInstanceCall = this instanceof Resource; 562 | var value = isInstanceCall ? data : (action.isArray ? [] : new Resource(data)); 563 | var httpConfig = {}; 564 | var responseInterceptor = action.interceptor && action.interceptor.response || 565 | defaultResponseInterceptor; 566 | var responseErrorInterceptor = action.interceptor && action.interceptor.responseError || 567 | undefined; 568 | 569 | forEach(action, function(value, key) { 570 | if (key != 'params' && key != 'isArray' && key != 'interceptor') { 571 | httpConfig[key] = copy(value); 572 | } 573 | }); 574 | 575 | if (hasBody) httpConfig.data = data; 576 | route.setUrlParams(httpConfig, 577 | extend({}, extractParams(data, action.params || {}), params), 578 | action.url); 579 | 580 | var promise = $http(httpConfig).then(function(response) { 581 | var data = response.data, 582 | promise = value.$promise; 583 | 584 | if (data) { 585 | // Need to convert action.isArray to boolean in case it is undefined 586 | // jshint -W018 587 | if (angular.isArray(data) !== (!!action.isArray)) { 588 | throw $resourceMinErr('badcfg', 589 | 'Error in resource configuration for action `{0}`. Expected response to ' + 590 | 'contain an {1} but got an {2} (Request: {3} {4})', name, action.isArray ? 'array' : 'object', 591 | angular.isArray(data) ? 'array' : 'object', httpConfig.method, httpConfig.url); 592 | } 593 | // jshint +W018 594 | if (action.isArray) { 595 | value.length = 0; 596 | forEach(data, function(item) { 597 | if (typeof item === "object") { 598 | value.push(new Resource(item)); 599 | } else { 600 | // Valid JSON values may be string literals, and these should not be converted 601 | // into objects. These items will not have access to the Resource prototype 602 | // methods, but unfortunately there 603 | value.push(item); 604 | } 605 | }); 606 | } else { 607 | shallowClearAndCopy(data, value); 608 | value.$promise = promise; 609 | } 610 | } 611 | 612 | value.$resolved = true; 613 | 614 | response.resource = value; 615 | 616 | return response; 617 | }, function(response) { 618 | value.$resolved = true; 619 | 620 | (error || noop)(response); 621 | 622 | return $q.reject(response); 623 | }); 624 | 625 | promise = promise.then( 626 | function(response) { 627 | var value = responseInterceptor(response); 628 | (success || noop)(value, response.headers); 629 | return value; 630 | }, 631 | responseErrorInterceptor); 632 | 633 | if (!isInstanceCall) { 634 | // we are creating instance / collection 635 | // - set the initial promise 636 | // - return the instance / collection 637 | value.$promise = promise; 638 | value.$resolved = false; 639 | 640 | return value; 641 | } 642 | 643 | // instance call 644 | return promise; 645 | }; 646 | 647 | 648 | Resource.prototype['$' + name] = function(params, success, error) { 649 | if (isFunction(params)) { 650 | error = success; success = params; params = {}; 651 | } 652 | var result = Resource[name].call(this, params, this, success, error); 653 | return result.$promise || result; 654 | }; 655 | }); 656 | 657 | Resource.bind = function(additionalParamDefaults) { 658 | return resourceFactory(url, extend({}, paramDefaults, additionalParamDefaults), actions); 659 | }; 660 | 661 | return Resource; 662 | } 663 | 664 | return resourceFactory; 665 | }]; 666 | }); 667 | 668 | 669 | })(window, window.angular); 670 | -------------------------------------------------------------------------------- /node_modules/angular-route/angular-route.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license AngularJS v1.4.3 3 | * (c) 2010-2015 Google, Inc. http://angularjs.org 4 | * License: MIT 5 | */ 6 | (function(window, angular, undefined) {'use strict'; 7 | 8 | /** 9 | * @ngdoc module 10 | * @name ngRoute 11 | * @description 12 | * 13 | * # ngRoute 14 | * 15 | * The `ngRoute` module provides routing and deeplinking services and directives for angular apps. 16 | * 17 | * ## Example 18 | * See {@link ngRoute.$route#example $route} for an example of configuring and using `ngRoute`. 19 | * 20 | * 21 | *
22 | */ 23 | /* global -ngRouteModule */ 24 | var ngRouteModule = angular.module('ngRoute', ['ng']). 25 | provider('$route', $RouteProvider), 26 | $routeMinErr = angular.$$minErr('ngRoute'); 27 | 28 | /** 29 | * @ngdoc provider 30 | * @name $routeProvider 31 | * 32 | * @description 33 | * 34 | * Used for configuring routes. 35 | * 36 | * ## Example 37 | * See {@link ngRoute.$route#example $route} for an example of configuring and using `ngRoute`. 38 | * 39 | * ## Dependencies 40 | * Requires the {@link ngRoute `ngRoute`} module to be installed. 41 | */ 42 | function $RouteProvider() { 43 | function inherit(parent, extra) { 44 | return angular.extend(Object.create(parent), extra); 45 | } 46 | 47 | var routes = {}; 48 | 49 | /** 50 | * @ngdoc method 51 | * @name $routeProvider#when 52 | * 53 | * @param {string} path Route path (matched against `$location.path`). If `$location.path` 54 | * contains redundant trailing slash or is missing one, the route will still match and the 55 | * `$location.path` will be updated to add or drop the trailing slash to exactly match the 56 | * route definition. 57 | * 58 | * * `path` can contain named groups starting with a colon: e.g. `:name`. All characters up 59 | * to the next slash are matched and stored in `$routeParams` under the given `name` 60 | * when the route matches. 61 | * * `path` can contain named groups starting with a colon and ending with a star: 62 | * e.g.`:name*`. All characters are eagerly stored in `$routeParams` under the given `name` 63 | * when the route matches. 64 | * * `path` can contain optional named groups with a question mark: e.g.`:name?`. 65 | * 66 | * For example, routes like `/color/:color/largecode/:largecode*\/edit` will match 67 | * `/color/brown/largecode/code/with/slashes/edit` and extract: 68 | * 69 | * * `color: brown` 70 | * * `largecode: code/with/slashes`. 71 | * 72 | * 73 | * @param {Object} route Mapping information to be assigned to `$route.current` on route 74 | * match. 75 | * 76 | * Object properties: 77 | * 78 | * - `controller` – `{(string|function()=}` – Controller fn that should be associated with 79 | * newly created scope or the name of a {@link angular.Module#controller registered 80 | * controller} if passed as a string. 81 | * - `controllerAs` – `{string=}` – An identifier name for a reference to the controller. 82 | * If present, the controller will be published to scope under the `controllerAs` name. 83 | * - `template` – `{string=|function()=}` – html template as a string or a function that 84 | * returns an html template as a string which should be used by {@link 85 | * ngRoute.directive:ngView ngView} or {@link ng.directive:ngInclude ngInclude} directives. 86 | * This property takes precedence over `templateUrl`. 87 | * 88 | * If `template` is a function, it will be called with the following parameters: 89 | * 90 | * - `{Array.}` - route parameters extracted from the current 91 | * `$location.path()` by applying the current route 92 | * 93 | * - `templateUrl` – `{string=|function()=}` – path or function that returns a path to an html 94 | * template that should be used by {@link ngRoute.directive:ngView ngView}. 95 | * 96 | * If `templateUrl` is a function, it will be called with the following parameters: 97 | * 98 | * - `{Array.}` - route parameters extracted from the current 99 | * `$location.path()` by applying the current route 100 | * 101 | * - `resolve` - `{Object.=}` - An optional map of dependencies which should 102 | * be injected into the controller. If any of these dependencies are promises, the router 103 | * will wait for them all to be resolved or one to be rejected before the controller is 104 | * instantiated. 105 | * If all the promises are resolved successfully, the values of the resolved promises are 106 | * injected and {@link ngRoute.$route#$routeChangeSuccess $routeChangeSuccess} event is 107 | * fired. If any of the promises are rejected the 108 | * {@link ngRoute.$route#$routeChangeError $routeChangeError} event is fired. The map object 109 | * is: 110 | * 111 | * - `key` – `{string}`: a name of a dependency to be injected into the controller. 112 | * - `factory` - `{string|function}`: If `string` then it is an alias for a service. 113 | * Otherwise if function, then it is {@link auto.$injector#invoke injected} 114 | * and the return value is treated as the dependency. If the result is a promise, it is 115 | * resolved before its value is injected into the controller. Be aware that 116 | * `ngRoute.$routeParams` will still refer to the previous route within these resolve 117 | * functions. Use `$route.current.params` to access the new route parameters, instead. 118 | * 119 | * - `redirectTo` – {(string|function())=} – value to update 120 | * {@link ng.$location $location} path with and trigger route redirection. 121 | * 122 | * If `redirectTo` is a function, it will be called with the following parameters: 123 | * 124 | * - `{Object.}` - route parameters extracted from the current 125 | * `$location.path()` by applying the current route templateUrl. 126 | * - `{string}` - current `$location.path()` 127 | * - `{Object}` - current `$location.search()` 128 | * 129 | * The custom `redirectTo` function is expected to return a string which will be used 130 | * to update `$location.path()` and `$location.search()`. 131 | * 132 | * - `[reloadOnSearch=true]` - {boolean=} - reload route when only `$location.search()` 133 | * or `$location.hash()` changes. 134 | * 135 | * If the option is set to `false` and url in the browser changes, then 136 | * `$routeUpdate` event is broadcasted on the root scope. 137 | * 138 | * - `[caseInsensitiveMatch=false]` - {boolean=} - match routes without being case sensitive 139 | * 140 | * If the option is set to `true`, then the particular route can be matched without being 141 | * case sensitive 142 | * 143 | * @returns {Object} self 144 | * 145 | * @description 146 | * Adds a new route definition to the `$route` service. 147 | */ 148 | this.when = function(path, route) { 149 | //copy original route object to preserve params inherited from proto chain 150 | var routeCopy = angular.copy(route); 151 | if (angular.isUndefined(routeCopy.reloadOnSearch)) { 152 | routeCopy.reloadOnSearch = true; 153 | } 154 | if (angular.isUndefined(routeCopy.caseInsensitiveMatch)) { 155 | routeCopy.caseInsensitiveMatch = this.caseInsensitiveMatch; 156 | } 157 | routes[path] = angular.extend( 158 | routeCopy, 159 | path && pathRegExp(path, routeCopy) 160 | ); 161 | 162 | // create redirection for trailing slashes 163 | if (path) { 164 | var redirectPath = (path[path.length - 1] == '/') 165 | ? path.substr(0, path.length - 1) 166 | : path + '/'; 167 | 168 | routes[redirectPath] = angular.extend( 169 | {redirectTo: path}, 170 | pathRegExp(redirectPath, routeCopy) 171 | ); 172 | } 173 | 174 | return this; 175 | }; 176 | 177 | /** 178 | * @ngdoc property 179 | * @name $routeProvider#caseInsensitiveMatch 180 | * @description 181 | * 182 | * A boolean property indicating if routes defined 183 | * using this provider should be matched using a case insensitive 184 | * algorithm. Defaults to `false`. 185 | */ 186 | this.caseInsensitiveMatch = false; 187 | 188 | /** 189 | * @param path {string} path 190 | * @param opts {Object} options 191 | * @return {?Object} 192 | * 193 | * @description 194 | * Normalizes the given path, returning a regular expression 195 | * and the original path. 196 | * 197 | * Inspired by pathRexp in visionmedia/express/lib/utils.js. 198 | */ 199 | function pathRegExp(path, opts) { 200 | var insensitive = opts.caseInsensitiveMatch, 201 | ret = { 202 | originalPath: path, 203 | regexp: path 204 | }, 205 | keys = ret.keys = []; 206 | 207 | path = path 208 | .replace(/([().])/g, '\\$1') 209 | .replace(/(\/)?:(\w+)([\?\*])?/g, function(_, slash, key, option) { 210 | var optional = option === '?' ? option : null; 211 | var star = option === '*' ? option : null; 212 | keys.push({ name: key, optional: !!optional }); 213 | slash = slash || ''; 214 | return '' 215 | + (optional ? '' : slash) 216 | + '(?:' 217 | + (optional ? slash : '') 218 | + (star && '(.+?)' || '([^/]+)') 219 | + (optional || '') 220 | + ')' 221 | + (optional || ''); 222 | }) 223 | .replace(/([\/$\*])/g, '\\$1'); 224 | 225 | ret.regexp = new RegExp('^' + path + '$', insensitive ? 'i' : ''); 226 | return ret; 227 | } 228 | 229 | /** 230 | * @ngdoc method 231 | * @name $routeProvider#otherwise 232 | * 233 | * @description 234 | * Sets route definition that will be used on route change when no other route definition 235 | * is matched. 236 | * 237 | * @param {Object|string} params Mapping information to be assigned to `$route.current`. 238 | * If called with a string, the value maps to `redirectTo`. 239 | * @returns {Object} self 240 | */ 241 | this.otherwise = function(params) { 242 | if (typeof params === 'string') { 243 | params = {redirectTo: params}; 244 | } 245 | this.when(null, params); 246 | return this; 247 | }; 248 | 249 | 250 | this.$get = ['$rootScope', 251 | '$location', 252 | '$routeParams', 253 | '$q', 254 | '$injector', 255 | '$templateRequest', 256 | '$sce', 257 | function($rootScope, $location, $routeParams, $q, $injector, $templateRequest, $sce) { 258 | 259 | /** 260 | * @ngdoc service 261 | * @name $route 262 | * @requires $location 263 | * @requires $routeParams 264 | * 265 | * @property {Object} current Reference to the current route definition. 266 | * The route definition contains: 267 | * 268 | * - `controller`: The controller constructor as define in route definition. 269 | * - `locals`: A map of locals which is used by {@link ng.$controller $controller} service for 270 | * controller instantiation. The `locals` contain 271 | * the resolved values of the `resolve` map. Additionally the `locals` also contain: 272 | * 273 | * - `$scope` - The current route scope. 274 | * - `$template` - The current route template HTML. 275 | * 276 | * @property {Object} routes Object with all route configuration Objects as its properties. 277 | * 278 | * @description 279 | * `$route` is used for deep-linking URLs to controllers and views (HTML partials). 280 | * It watches `$location.url()` and tries to map the path to an existing route definition. 281 | * 282 | * Requires the {@link ngRoute `ngRoute`} module to be installed. 283 | * 284 | * You can define routes through {@link ngRoute.$routeProvider $routeProvider}'s API. 285 | * 286 | * The `$route` service is typically used in conjunction with the 287 | * {@link ngRoute.directive:ngView `ngView`} directive and the 288 | * {@link ngRoute.$routeParams `$routeParams`} service. 289 | * 290 | * @example 291 | * This example shows how changing the URL hash causes the `$route` to match a route against the 292 | * URL, and the `ngView` pulls in the partial. 293 | * 294 | * 296 | * 297 | *
298 | * Choose: 299 | * Moby | 300 | * Moby: Ch1 | 301 | * Gatsby | 302 | * Gatsby: Ch4 | 303 | * Scarlet Letter
304 | * 305 | *
306 | * 307 | *
308 | * 309 | *
$location.path() = {{$location.path()}}
310 | *
$route.current.templateUrl = {{$route.current.templateUrl}}
311 | *
$route.current.params = {{$route.current.params}}
312 | *
$route.current.scope.name = {{$route.current.scope.name}}
313 | *
$routeParams = {{$routeParams}}
314 | *
315 | *
316 | * 317 | * 318 | * controller: {{name}}
319 | * Book Id: {{params.bookId}}
320 | *
321 | * 322 | * 323 | * controller: {{name}}
324 | * Book Id: {{params.bookId}}
325 | * Chapter Id: {{params.chapterId}} 326 | *
327 | * 328 | * 329 | * angular.module('ngRouteExample', ['ngRoute']) 330 | * 331 | * .controller('MainController', function($scope, $route, $routeParams, $location) { 332 | * $scope.$route = $route; 333 | * $scope.$location = $location; 334 | * $scope.$routeParams = $routeParams; 335 | * }) 336 | * 337 | * .controller('BookController', function($scope, $routeParams) { 338 | * $scope.name = "BookController"; 339 | * $scope.params = $routeParams; 340 | * }) 341 | * 342 | * .controller('ChapterController', function($scope, $routeParams) { 343 | * $scope.name = "ChapterController"; 344 | * $scope.params = $routeParams; 345 | * }) 346 | * 347 | * .config(function($routeProvider, $locationProvider) { 348 | * $routeProvider 349 | * .when('/Book/:bookId', { 350 | * templateUrl: 'book.html', 351 | * controller: 'BookController', 352 | * resolve: { 353 | * // I will cause a 1 second delay 354 | * delay: function($q, $timeout) { 355 | * var delay = $q.defer(); 356 | * $timeout(delay.resolve, 1000); 357 | * return delay.promise; 358 | * } 359 | * } 360 | * }) 361 | * .when('/Book/:bookId/ch/:chapterId', { 362 | * templateUrl: 'chapter.html', 363 | * controller: 'ChapterController' 364 | * }); 365 | * 366 | * // configure html5 to get links working on jsfiddle 367 | * $locationProvider.html5Mode(true); 368 | * }); 369 | * 370 | * 371 | * 372 | * 373 | * it('should load and compile correct template', function() { 374 | * element(by.linkText('Moby: Ch1')).click(); 375 | * var content = element(by.css('[ng-view]')).getText(); 376 | * expect(content).toMatch(/controller\: ChapterController/); 377 | * expect(content).toMatch(/Book Id\: Moby/); 378 | * expect(content).toMatch(/Chapter Id\: 1/); 379 | * 380 | * element(by.partialLinkText('Scarlet')).click(); 381 | * 382 | * content = element(by.css('[ng-view]')).getText(); 383 | * expect(content).toMatch(/controller\: BookController/); 384 | * expect(content).toMatch(/Book Id\: Scarlet/); 385 | * }); 386 | * 387 | *
388 | */ 389 | 390 | /** 391 | * @ngdoc event 392 | * @name $route#$routeChangeStart 393 | * @eventType broadcast on root scope 394 | * @description 395 | * Broadcasted before a route change. At this point the route services starts 396 | * resolving all of the dependencies needed for the route change to occur. 397 | * Typically this involves fetching the view template as well as any dependencies 398 | * defined in `resolve` route property. Once all of the dependencies are resolved 399 | * `$routeChangeSuccess` is fired. 400 | * 401 | * The route change (and the `$location` change that triggered it) can be prevented 402 | * by calling `preventDefault` method of the event. See {@link ng.$rootScope.Scope#$on} 403 | * for more details about event object. 404 | * 405 | * @param {Object} angularEvent Synthetic event object. 406 | * @param {Route} next Future route information. 407 | * @param {Route} current Current route information. 408 | */ 409 | 410 | /** 411 | * @ngdoc event 412 | * @name $route#$routeChangeSuccess 413 | * @eventType broadcast on root scope 414 | * @description 415 | * Broadcasted after a route change has happened successfully. 416 | * The `resolve` dependencies are now available in the `current.locals` property. 417 | * 418 | * {@link ngRoute.directive:ngView ngView} listens for the directive 419 | * to instantiate the controller and render the view. 420 | * 421 | * @param {Object} angularEvent Synthetic event object. 422 | * @param {Route} current Current route information. 423 | * @param {Route|Undefined} previous Previous route information, or undefined if current is 424 | * first route entered. 425 | */ 426 | 427 | /** 428 | * @ngdoc event 429 | * @name $route#$routeChangeError 430 | * @eventType broadcast on root scope 431 | * @description 432 | * Broadcasted if any of the resolve promises are rejected. 433 | * 434 | * @param {Object} angularEvent Synthetic event object 435 | * @param {Route} current Current route information. 436 | * @param {Route} previous Previous route information. 437 | * @param {Route} rejection Rejection of the promise. Usually the error of the failed promise. 438 | */ 439 | 440 | /** 441 | * @ngdoc event 442 | * @name $route#$routeUpdate 443 | * @eventType broadcast on root scope 444 | * @description 445 | * The `reloadOnSearch` property has been set to false, and we are reusing the same 446 | * instance of the Controller. 447 | * 448 | * @param {Object} angularEvent Synthetic event object 449 | * @param {Route} current Current/previous route information. 450 | */ 451 | 452 | var forceReload = false, 453 | preparedRoute, 454 | preparedRouteIsUpdateOnly, 455 | $route = { 456 | routes: routes, 457 | 458 | /** 459 | * @ngdoc method 460 | * @name $route#reload 461 | * 462 | * @description 463 | * Causes `$route` service to reload the current route even if 464 | * {@link ng.$location $location} hasn't changed. 465 | * 466 | * As a result of that, {@link ngRoute.directive:ngView ngView} 467 | * creates new scope and reinstantiates the controller. 468 | */ 469 | reload: function() { 470 | forceReload = true; 471 | $rootScope.$evalAsync(function() { 472 | // Don't support cancellation of a reload for now... 473 | prepareRoute(); 474 | commitRoute(); 475 | }); 476 | }, 477 | 478 | /** 479 | * @ngdoc method 480 | * @name $route#updateParams 481 | * 482 | * @description 483 | * Causes `$route` service to update the current URL, replacing 484 | * current route parameters with those specified in `newParams`. 485 | * Provided property names that match the route's path segment 486 | * definitions will be interpolated into the location's path, while 487 | * remaining properties will be treated as query params. 488 | * 489 | * @param {!Object} newParams mapping of URL parameter names to values 490 | */ 491 | updateParams: function(newParams) { 492 | if (this.current && this.current.$$route) { 493 | newParams = angular.extend({}, this.current.params, newParams); 494 | $location.path(interpolate(this.current.$$route.originalPath, newParams)); 495 | // interpolate modifies newParams, only query params are left 496 | $location.search(newParams); 497 | } else { 498 | throw $routeMinErr('norout', 'Tried updating route when with no current route'); 499 | } 500 | } 501 | }; 502 | 503 | $rootScope.$on('$locationChangeStart', prepareRoute); 504 | $rootScope.$on('$locationChangeSuccess', commitRoute); 505 | 506 | return $route; 507 | 508 | ///////////////////////////////////////////////////// 509 | 510 | /** 511 | * @param on {string} current url 512 | * @param route {Object} route regexp to match the url against 513 | * @return {?Object} 514 | * 515 | * @description 516 | * Check if the route matches the current url. 517 | * 518 | * Inspired by match in 519 | * visionmedia/express/lib/router/router.js. 520 | */ 521 | function switchRouteMatcher(on, route) { 522 | var keys = route.keys, 523 | params = {}; 524 | 525 | if (!route.regexp) return null; 526 | 527 | var m = route.regexp.exec(on); 528 | if (!m) return null; 529 | 530 | for (var i = 1, len = m.length; i < len; ++i) { 531 | var key = keys[i - 1]; 532 | 533 | var val = m[i]; 534 | 535 | if (key && val) { 536 | params[key.name] = val; 537 | } 538 | } 539 | return params; 540 | } 541 | 542 | function prepareRoute($locationEvent) { 543 | var lastRoute = $route.current; 544 | 545 | preparedRoute = parseRoute(); 546 | preparedRouteIsUpdateOnly = preparedRoute && lastRoute && preparedRoute.$$route === lastRoute.$$route 547 | && angular.equals(preparedRoute.pathParams, lastRoute.pathParams) 548 | && !preparedRoute.reloadOnSearch && !forceReload; 549 | 550 | if (!preparedRouteIsUpdateOnly && (lastRoute || preparedRoute)) { 551 | if ($rootScope.$broadcast('$routeChangeStart', preparedRoute, lastRoute).defaultPrevented) { 552 | if ($locationEvent) { 553 | $locationEvent.preventDefault(); 554 | } 555 | } 556 | } 557 | } 558 | 559 | function commitRoute() { 560 | var lastRoute = $route.current; 561 | var nextRoute = preparedRoute; 562 | 563 | if (preparedRouteIsUpdateOnly) { 564 | lastRoute.params = nextRoute.params; 565 | angular.copy(lastRoute.params, $routeParams); 566 | $rootScope.$broadcast('$routeUpdate', lastRoute); 567 | } else if (nextRoute || lastRoute) { 568 | forceReload = false; 569 | $route.current = nextRoute; 570 | if (nextRoute) { 571 | if (nextRoute.redirectTo) { 572 | if (angular.isString(nextRoute.redirectTo)) { 573 | $location.path(interpolate(nextRoute.redirectTo, nextRoute.params)).search(nextRoute.params) 574 | .replace(); 575 | } else { 576 | $location.url(nextRoute.redirectTo(nextRoute.pathParams, $location.path(), $location.search())) 577 | .replace(); 578 | } 579 | } 580 | } 581 | 582 | $q.when(nextRoute). 583 | then(function() { 584 | if (nextRoute) { 585 | var locals = angular.extend({}, nextRoute.resolve), 586 | template, templateUrl; 587 | 588 | angular.forEach(locals, function(value, key) { 589 | locals[key] = angular.isString(value) ? 590 | $injector.get(value) : $injector.invoke(value, null, null, key); 591 | }); 592 | 593 | if (angular.isDefined(template = nextRoute.template)) { 594 | if (angular.isFunction(template)) { 595 | template = template(nextRoute.params); 596 | } 597 | } else if (angular.isDefined(templateUrl = nextRoute.templateUrl)) { 598 | if (angular.isFunction(templateUrl)) { 599 | templateUrl = templateUrl(nextRoute.params); 600 | } 601 | if (angular.isDefined(templateUrl)) { 602 | nextRoute.loadedTemplateUrl = $sce.valueOf(templateUrl); 603 | template = $templateRequest(templateUrl); 604 | } 605 | } 606 | if (angular.isDefined(template)) { 607 | locals['$template'] = template; 608 | } 609 | return $q.all(locals); 610 | } 611 | }). 612 | then(function(locals) { 613 | // after route change 614 | if (nextRoute == $route.current) { 615 | if (nextRoute) { 616 | nextRoute.locals = locals; 617 | angular.copy(nextRoute.params, $routeParams); 618 | } 619 | $rootScope.$broadcast('$routeChangeSuccess', nextRoute, lastRoute); 620 | } 621 | }, function(error) { 622 | if (nextRoute == $route.current) { 623 | $rootScope.$broadcast('$routeChangeError', nextRoute, lastRoute, error); 624 | } 625 | }); 626 | } 627 | } 628 | 629 | 630 | /** 631 | * @returns {Object} the current active route, by matching it against the URL 632 | */ 633 | function parseRoute() { 634 | // Match a route 635 | var params, match; 636 | angular.forEach(routes, function(route, path) { 637 | if (!match && (params = switchRouteMatcher($location.path(), route))) { 638 | match = inherit(route, { 639 | params: angular.extend({}, $location.search(), params), 640 | pathParams: params}); 641 | match.$$route = route; 642 | } 643 | }); 644 | // No route matched; fallback to "otherwise" route 645 | return match || routes[null] && inherit(routes[null], {params: {}, pathParams:{}}); 646 | } 647 | 648 | /** 649 | * @returns {string} interpolation of the redirect path with the parameters 650 | */ 651 | function interpolate(string, params) { 652 | var result = []; 653 | angular.forEach((string || '').split(':'), function(segment, i) { 654 | if (i === 0) { 655 | result.push(segment); 656 | } else { 657 | var segmentMatch = segment.match(/(\w+)(?:[?*])?(.*)/); 658 | var key = segmentMatch[1]; 659 | result.push(params[key]); 660 | result.push(segmentMatch[2] || ''); 661 | delete params[key]; 662 | } 663 | }); 664 | return result.join(''); 665 | } 666 | }]; 667 | } 668 | 669 | ngRouteModule.provider('$routeParams', $RouteParamsProvider); 670 | 671 | 672 | /** 673 | * @ngdoc service 674 | * @name $routeParams 675 | * @requires $route 676 | * 677 | * @description 678 | * The `$routeParams` service allows you to retrieve the current set of route parameters. 679 | * 680 | * Requires the {@link ngRoute `ngRoute`} module to be installed. 681 | * 682 | * The route parameters are a combination of {@link ng.$location `$location`}'s 683 | * {@link ng.$location#search `search()`} and {@link ng.$location#path `path()`}. 684 | * The `path` parameters are extracted when the {@link ngRoute.$route `$route`} path is matched. 685 | * 686 | * In case of parameter name collision, `path` params take precedence over `search` params. 687 | * 688 | * The service guarantees that the identity of the `$routeParams` object will remain unchanged 689 | * (but its properties will likely change) even when a route change occurs. 690 | * 691 | * Note that the `$routeParams` are only updated *after* a route change completes successfully. 692 | * This means that you cannot rely on `$routeParams` being correct in route resolve functions. 693 | * Instead you can use `$route.current.params` to access the new route's parameters. 694 | * 695 | * @example 696 | * ```js 697 | * // Given: 698 | * // URL: http://server.com/index.html#/Chapter/1/Section/2?search=moby 699 | * // Route: /Chapter/:chapterId/Section/:sectionId 700 | * // 701 | * // Then 702 | * $routeParams ==> {chapterId:'1', sectionId:'2', search:'moby'} 703 | * ``` 704 | */ 705 | function $RouteParamsProvider() { 706 | this.$get = function() { return {}; }; 707 | } 708 | 709 | ngRouteModule.directive('ngView', ngViewFactory); 710 | ngRouteModule.directive('ngView', ngViewFillContentFactory); 711 | 712 | 713 | /** 714 | * @ngdoc directive 715 | * @name ngView 716 | * @restrict ECA 717 | * 718 | * @description 719 | * # Overview 720 | * `ngView` is a directive that complements the {@link ngRoute.$route $route} service by 721 | * including the rendered template of the current route into the main layout (`index.html`) file. 722 | * Every time the current route changes, the included view changes with it according to the 723 | * configuration of the `$route` service. 724 | * 725 | * Requires the {@link ngRoute `ngRoute`} module to be installed. 726 | * 727 | * @animations 728 | * enter - animation is used to bring new content into the browser. 729 | * leave - animation is used to animate existing content away. 730 | * 731 | * The enter and leave animation occur concurrently. 732 | * 733 | * @scope 734 | * @priority 400 735 | * @param {string=} onload Expression to evaluate whenever the view updates. 736 | * 737 | * @param {string=} autoscroll Whether `ngView` should call {@link ng.$anchorScroll 738 | * $anchorScroll} to scroll the viewport after the view is updated. 739 | * 740 | * - If the attribute is not set, disable scrolling. 741 | * - If the attribute is set without value, enable scrolling. 742 | * - Otherwise enable scrolling only if the `autoscroll` attribute value evaluated 743 | * as an expression yields a truthy value. 744 | * @example 745 | 748 | 749 |
750 | Choose: 751 | Moby | 752 | Moby: Ch1 | 753 | Gatsby | 754 | Gatsby: Ch4 | 755 | Scarlet Letter
756 | 757 |
758 |
759 |
760 |
761 | 762 |
$location.path() = {{main.$location.path()}}
763 |
$route.current.templateUrl = {{main.$route.current.templateUrl}}
764 |
$route.current.params = {{main.$route.current.params}}
765 |
$routeParams = {{main.$routeParams}}
766 |
767 |
768 | 769 | 770 |
771 | controller: {{book.name}}
772 | Book Id: {{book.params.bookId}}
773 |
774 |
775 | 776 | 777 |
778 | controller: {{chapter.name}}
779 | Book Id: {{chapter.params.bookId}}
780 | Chapter Id: {{chapter.params.chapterId}} 781 |
782 |
783 | 784 | 785 | .view-animate-container { 786 | position:relative; 787 | height:100px!important; 788 | background:white; 789 | border:1px solid black; 790 | height:40px; 791 | overflow:hidden; 792 | } 793 | 794 | .view-animate { 795 | padding:10px; 796 | } 797 | 798 | .view-animate.ng-enter, .view-animate.ng-leave { 799 | -webkit-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 1.5s; 800 | transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 1.5s; 801 | 802 | display:block; 803 | width:100%; 804 | border-left:1px solid black; 805 | 806 | position:absolute; 807 | top:0; 808 | left:0; 809 | right:0; 810 | bottom:0; 811 | padding:10px; 812 | } 813 | 814 | .view-animate.ng-enter { 815 | left:100%; 816 | } 817 | .view-animate.ng-enter.ng-enter-active { 818 | left:0; 819 | } 820 | .view-animate.ng-leave.ng-leave-active { 821 | left:-100%; 822 | } 823 | 824 | 825 | 826 | angular.module('ngViewExample', ['ngRoute', 'ngAnimate']) 827 | .config(['$routeProvider', '$locationProvider', 828 | function($routeProvider, $locationProvider) { 829 | $routeProvider 830 | .when('/Book/:bookId', { 831 | templateUrl: 'book.html', 832 | controller: 'BookCtrl', 833 | controllerAs: 'book' 834 | }) 835 | .when('/Book/:bookId/ch/:chapterId', { 836 | templateUrl: 'chapter.html', 837 | controller: 'ChapterCtrl', 838 | controllerAs: 'chapter' 839 | }); 840 | 841 | $locationProvider.html5Mode(true); 842 | }]) 843 | .controller('MainCtrl', ['$route', '$routeParams', '$location', 844 | function($route, $routeParams, $location) { 845 | this.$route = $route; 846 | this.$location = $location; 847 | this.$routeParams = $routeParams; 848 | }]) 849 | .controller('BookCtrl', ['$routeParams', function($routeParams) { 850 | this.name = "BookCtrl"; 851 | this.params = $routeParams; 852 | }]) 853 | .controller('ChapterCtrl', ['$routeParams', function($routeParams) { 854 | this.name = "ChapterCtrl"; 855 | this.params = $routeParams; 856 | }]); 857 | 858 | 859 | 860 | 861 | it('should load and compile correct template', function() { 862 | element(by.linkText('Moby: Ch1')).click(); 863 | var content = element(by.css('[ng-view]')).getText(); 864 | expect(content).toMatch(/controller\: ChapterCtrl/); 865 | expect(content).toMatch(/Book Id\: Moby/); 866 | expect(content).toMatch(/Chapter Id\: 1/); 867 | 868 | element(by.partialLinkText('Scarlet')).click(); 869 | 870 | content = element(by.css('[ng-view]')).getText(); 871 | expect(content).toMatch(/controller\: BookCtrl/); 872 | expect(content).toMatch(/Book Id\: Scarlet/); 873 | }); 874 | 875 |
876 | */ 877 | 878 | 879 | /** 880 | * @ngdoc event 881 | * @name ngView#$viewContentLoaded 882 | * @eventType emit on the current ngView scope 883 | * @description 884 | * Emitted every time the ngView content is reloaded. 885 | */ 886 | ngViewFactory.$inject = ['$route', '$anchorScroll', '$animate']; 887 | function ngViewFactory($route, $anchorScroll, $animate) { 888 | return { 889 | restrict: 'ECA', 890 | terminal: true, 891 | priority: 400, 892 | transclude: 'element', 893 | link: function(scope, $element, attr, ctrl, $transclude) { 894 | var currentScope, 895 | currentElement, 896 | previousLeaveAnimation, 897 | autoScrollExp = attr.autoscroll, 898 | onloadExp = attr.onload || ''; 899 | 900 | scope.$on('$routeChangeSuccess', update); 901 | update(); 902 | 903 | function cleanupLastView() { 904 | if (previousLeaveAnimation) { 905 | $animate.cancel(previousLeaveAnimation); 906 | previousLeaveAnimation = null; 907 | } 908 | 909 | if (currentScope) { 910 | currentScope.$destroy(); 911 | currentScope = null; 912 | } 913 | if (currentElement) { 914 | previousLeaveAnimation = $animate.leave(currentElement); 915 | previousLeaveAnimation.then(function() { 916 | previousLeaveAnimation = null; 917 | }); 918 | currentElement = null; 919 | } 920 | } 921 | 922 | function update() { 923 | var locals = $route.current && $route.current.locals, 924 | template = locals && locals.$template; 925 | 926 | if (angular.isDefined(template)) { 927 | var newScope = scope.$new(); 928 | var current = $route.current; 929 | 930 | // Note: This will also link all children of ng-view that were contained in the original 931 | // html. If that content contains controllers, ... they could pollute/change the scope. 932 | // However, using ng-view on an element with additional content does not make sense... 933 | // Note: We can't remove them in the cloneAttchFn of $transclude as that 934 | // function is called before linking the content, which would apply child 935 | // directives to non existing elements. 936 | var clone = $transclude(newScope, function(clone) { 937 | $animate.enter(clone, null, currentElement || $element).then(function onNgViewEnter() { 938 | if (angular.isDefined(autoScrollExp) 939 | && (!autoScrollExp || scope.$eval(autoScrollExp))) { 940 | $anchorScroll(); 941 | } 942 | }); 943 | cleanupLastView(); 944 | }); 945 | 946 | currentElement = clone; 947 | currentScope = current.scope = newScope; 948 | currentScope.$emit('$viewContentLoaded'); 949 | currentScope.$eval(onloadExp); 950 | } else { 951 | cleanupLastView(); 952 | } 953 | } 954 | } 955 | }; 956 | } 957 | 958 | // This directive is called during the $transclude call of the first `ngView` directive. 959 | // It will replace and compile the content of the element with the loaded template. 960 | // We need this directive so that the element content is already filled when 961 | // the link function of another directive on the same element as ngView 962 | // is called. 963 | ngViewFillContentFactory.$inject = ['$compile', '$controller', '$route']; 964 | function ngViewFillContentFactory($compile, $controller, $route) { 965 | return { 966 | restrict: 'ECA', 967 | priority: -400, 968 | link: function(scope, $element) { 969 | var current = $route.current, 970 | locals = current.locals; 971 | 972 | $element.html(locals.$template); 973 | 974 | var link = $compile($element.contents()); 975 | 976 | if (current.controller) { 977 | locals.$scope = scope; 978 | var controller = $controller(current.controller, locals); 979 | if (current.controllerAs) { 980 | scope[current.controllerAs] = controller; 981 | } 982 | $element.data('$ngControllerController', controller); 983 | $element.children().data('$ngControllerController', controller); 984 | } 985 | 986 | link(scope); 987 | } 988 | }; 989 | } 990 | 991 | 992 | })(window, window.angular); 993 | -------------------------------------------------------------------------------- /node_modules/todomvc-app-css/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | button { 8 | margin: 0; 9 | padding: 0; 10 | border: 0; 11 | background: none; 12 | font-size: 100%; 13 | vertical-align: baseline; 14 | font-family: inherit; 15 | font-weight: inherit; 16 | color: inherit; 17 | -webkit-appearance: none; 18 | appearance: none; 19 | -webkit-font-smoothing: antialiased; 20 | -moz-osx-font-smoothing: grayscale; 21 | } 22 | 23 | body { 24 | font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; 25 | line-height: 1.4em; 26 | background: #f5f5f5; 27 | color: #4d4d4d; 28 | min-width: 230px; 29 | max-width: 550px; 30 | margin: 0 auto; 31 | -webkit-font-smoothing: antialiased; 32 | -moz-osx-font-smoothing: grayscale; 33 | font-weight: 300; 34 | } 35 | 36 | :focus { 37 | outline: 0; 38 | } 39 | 40 | .hidden { 41 | display: none; 42 | } 43 | 44 | .todoapp { 45 | background: #fff; 46 | margin: 130px 0 40px 0; 47 | position: relative; 48 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 49 | 0 25px 50px 0 rgba(0, 0, 0, 0.1); 50 | } 51 | 52 | .todoapp input::-webkit-input-placeholder { 53 | font-style: italic; 54 | font-weight: 300; 55 | color: #e6e6e6; 56 | } 57 | 58 | .todoapp input::-moz-placeholder { 59 | font-style: italic; 60 | font-weight: 300; 61 | color: #e6e6e6; 62 | } 63 | 64 | .todoapp input::input-placeholder { 65 | font-style: italic; 66 | font-weight: 300; 67 | color: #e6e6e6; 68 | } 69 | 70 | .todoapp h1 { 71 | position: absolute; 72 | top: -155px; 73 | width: 100%; 74 | font-size: 100px; 75 | font-weight: 100; 76 | text-align: center; 77 | color: rgba(175, 47, 47, 0.15); 78 | -webkit-text-rendering: optimizeLegibility; 79 | -moz-text-rendering: optimizeLegibility; 80 | text-rendering: optimizeLegibility; 81 | } 82 | 83 | .new-todo, 84 | .edit { 85 | position: relative; 86 | margin: 0; 87 | width: 100%; 88 | font-size: 24px; 89 | font-family: inherit; 90 | font-weight: inherit; 91 | line-height: 1.4em; 92 | border: 0; 93 | color: inherit; 94 | padding: 6px; 95 | border: 1px solid #999; 96 | box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); 97 | box-sizing: border-box; 98 | -webkit-font-smoothing: antialiased; 99 | -moz-osx-font-smoothing: grayscale; 100 | } 101 | 102 | .new-todo { 103 | padding: 16px 16px 16px 60px; 104 | border: none; 105 | background: rgba(0, 0, 0, 0.003); 106 | box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); 107 | } 108 | 109 | .main { 110 | position: relative; 111 | z-index: 2; 112 | border-top: 1px solid #e6e6e6; 113 | } 114 | 115 | .toggle-all { 116 | text-align: center; 117 | border: none; /* Mobile Safari */ 118 | opacity: 0; 119 | position: absolute; 120 | } 121 | 122 | .toggle-all + label { 123 | width: 60px; 124 | height: 34px; 125 | font-size: 0; 126 | position: absolute; 127 | top: -52px; 128 | left: -13px; 129 | -webkit-transform: rotate(90deg); 130 | transform: rotate(90deg); 131 | } 132 | 133 | .toggle-all + label:before { 134 | content: '❯'; 135 | font-size: 22px; 136 | color: #e6e6e6; 137 | padding: 10px 27px 10px 27px; 138 | } 139 | 140 | .toggle-all:checked + label:before { 141 | color: #737373; 142 | } 143 | 144 | .todo-list { 145 | margin: 0; 146 | padding: 0; 147 | list-style: none; 148 | } 149 | 150 | .todo-list li { 151 | position: relative; 152 | font-size: 24px; 153 | border-bottom: 1px solid #ededed; 154 | } 155 | 156 | .todo-list li:last-child { 157 | border-bottom: none; 158 | } 159 | 160 | .todo-list li.editing { 161 | border-bottom: none; 162 | padding: 0; 163 | } 164 | 165 | .todo-list li.editing .edit { 166 | display: block; 167 | width: 506px; 168 | padding: 12px 16px; 169 | margin: 0 0 0 43px; 170 | } 171 | 172 | .todo-list li.editing .view { 173 | display: none; 174 | } 175 | 176 | .todo-list li .toggle { 177 | text-align: center; 178 | width: 40px; 179 | /* auto, since non-WebKit browsers doesn't support input styling */ 180 | height: auto; 181 | position: absolute; 182 | top: 0; 183 | bottom: 0; 184 | margin: auto 0; 185 | border: none; /* Mobile Safari */ 186 | -webkit-appearance: none; 187 | appearance: none; 188 | } 189 | 190 | .todo-list li .toggle { 191 | opacity: 0; 192 | } 193 | 194 | .todo-list li .toggle + label { 195 | /* 196 | Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433 197 | IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/ 198 | */ 199 | background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); 200 | background-repeat: no-repeat; 201 | background-position: center left; 202 | } 203 | 204 | .todo-list li .toggle:checked + label { 205 | background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E'); 206 | } 207 | 208 | .todo-list li label { 209 | word-break: break-all; 210 | padding: 15px 15px 15px 60px; 211 | display: block; 212 | line-height: 1.2; 213 | transition: color 0.4s; 214 | } 215 | 216 | .todo-list li.completed label { 217 | color: #d9d9d9; 218 | text-decoration: line-through; 219 | } 220 | 221 | .todo-list li .destroy { 222 | display: none; 223 | position: absolute; 224 | top: 0; 225 | right: 10px; 226 | bottom: 0; 227 | width: 40px; 228 | height: 40px; 229 | margin: auto 0; 230 | font-size: 30px; 231 | color: #cc9a9a; 232 | margin-bottom: 11px; 233 | transition: color 0.2s ease-out; 234 | } 235 | 236 | .todo-list li .destroy:hover { 237 | color: #af5b5e; 238 | } 239 | 240 | .todo-list li .destroy:after { 241 | content: '×'; 242 | } 243 | 244 | .todo-list li:hover .destroy { 245 | display: block; 246 | } 247 | 248 | .todo-list li .edit { 249 | display: none; 250 | } 251 | 252 | .todo-list li.editing:last-child { 253 | margin-bottom: -1px; 254 | } 255 | 256 | .footer { 257 | color: #777; 258 | padding: 10px 15px; 259 | height: 20px; 260 | text-align: center; 261 | border-top: 1px solid #e6e6e6; 262 | } 263 | 264 | .footer:before { 265 | content: ''; 266 | position: absolute; 267 | right: 0; 268 | bottom: 0; 269 | left: 0; 270 | height: 50px; 271 | overflow: hidden; 272 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 273 | 0 8px 0 -3px #f6f6f6, 274 | 0 9px 1px -3px rgba(0, 0, 0, 0.2), 275 | 0 16px 0 -6px #f6f6f6, 276 | 0 17px 2px -6px rgba(0, 0, 0, 0.2); 277 | } 278 | 279 | .todo-count { 280 | float: left; 281 | text-align: left; 282 | } 283 | 284 | .todo-count strong { 285 | font-weight: 300; 286 | } 287 | 288 | .filters { 289 | margin: 0; 290 | padding: 0; 291 | list-style: none; 292 | position: absolute; 293 | right: 0; 294 | left: 0; 295 | } 296 | 297 | .filters li { 298 | display: inline; 299 | } 300 | 301 | .filters li a { 302 | color: inherit; 303 | margin: 3px; 304 | padding: 3px 7px; 305 | text-decoration: none; 306 | border: 1px solid transparent; 307 | border-radius: 3px; 308 | } 309 | 310 | .filters li a:hover { 311 | border-color: rgba(175, 47, 47, 0.1); 312 | } 313 | 314 | .filters li a.selected { 315 | border-color: rgba(175, 47, 47, 0.2); 316 | } 317 | 318 | .clear-completed, 319 | html .clear-completed:active { 320 | float: right; 321 | position: relative; 322 | line-height: 20px; 323 | text-decoration: none; 324 | cursor: pointer; 325 | } 326 | 327 | .clear-completed:hover { 328 | text-decoration: underline; 329 | } 330 | 331 | .info { 332 | margin: 65px auto 0; 333 | color: #bfbfbf; 334 | font-size: 10px; 335 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); 336 | text-align: center; 337 | } 338 | 339 | .info p { 340 | line-height: 1; 341 | } 342 | 343 | .info a { 344 | color: inherit; 345 | text-decoration: none; 346 | font-weight: 400; 347 | } 348 | 349 | .info a:hover { 350 | text-decoration: underline; 351 | } 352 | 353 | /* 354 | Hack to remove background from Mobile Safari. 355 | Can't use it globally since it destroys checkboxes in Firefox 356 | */ 357 | @media screen and (-webkit-min-device-pixel-ratio:0) { 358 | .toggle-all, 359 | .todo-list li .toggle { 360 | background: none; 361 | } 362 | 363 | .todo-list li .toggle { 364 | height: 40px; 365 | } 366 | } 367 | 368 | @media (max-width: 430px) { 369 | .footer { 370 | height: 50px; 371 | } 372 | 373 | .filters { 374 | bottom: 10px; 375 | } 376 | } 377 | -------------------------------------------------------------------------------- /node_modules/todomvc-common/base.css: -------------------------------------------------------------------------------- 1 | hr { 2 | margin: 20px 0; 3 | border: 0; 4 | border-top: 1px dashed #c5c5c5; 5 | border-bottom: 1px dashed #f7f7f7; 6 | } 7 | 8 | .learn a { 9 | font-weight: normal; 10 | text-decoration: none; 11 | color: #b83f45; 12 | } 13 | 14 | .learn a:hover { 15 | text-decoration: underline; 16 | color: #787e7e; 17 | } 18 | 19 | .learn h3, 20 | .learn h4, 21 | .learn h5 { 22 | margin: 10px 0; 23 | font-weight: 500; 24 | line-height: 1.2; 25 | color: #000; 26 | } 27 | 28 | .learn h3 { 29 | font-size: 24px; 30 | } 31 | 32 | .learn h4 { 33 | font-size: 18px; 34 | } 35 | 36 | .learn h5 { 37 | margin-bottom: 0; 38 | font-size: 14px; 39 | } 40 | 41 | .learn ul { 42 | padding: 0; 43 | margin: 0 0 30px 25px; 44 | } 45 | 46 | .learn li { 47 | line-height: 20px; 48 | } 49 | 50 | .learn p { 51 | font-size: 15px; 52 | font-weight: 300; 53 | line-height: 1.3; 54 | margin-top: 0; 55 | margin-bottom: 0; 56 | } 57 | 58 | #issue-count { 59 | display: none; 60 | } 61 | 62 | .quote { 63 | border: none; 64 | margin: 20px 0 60px 0; 65 | } 66 | 67 | .quote p { 68 | font-style: italic; 69 | } 70 | 71 | .quote p:before { 72 | content: '“'; 73 | font-size: 50px; 74 | opacity: .15; 75 | position: absolute; 76 | top: -20px; 77 | left: 3px; 78 | } 79 | 80 | .quote p:after { 81 | content: '”'; 82 | font-size: 50px; 83 | opacity: .15; 84 | position: absolute; 85 | bottom: -42px; 86 | right: 3px; 87 | } 88 | 89 | .quote footer { 90 | position: absolute; 91 | bottom: -40px; 92 | right: 0; 93 | } 94 | 95 | .quote footer img { 96 | border-radius: 3px; 97 | } 98 | 99 | .quote footer a { 100 | margin-left: 5px; 101 | vertical-align: middle; 102 | } 103 | 104 | .speech-bubble { 105 | position: relative; 106 | padding: 10px; 107 | background: rgba(0, 0, 0, .04); 108 | border-radius: 5px; 109 | } 110 | 111 | .speech-bubble:after { 112 | content: ''; 113 | position: absolute; 114 | top: 100%; 115 | right: 30px; 116 | border: 13px solid transparent; 117 | border-top-color: rgba(0, 0, 0, .04); 118 | } 119 | 120 | .learn-bar > .learn { 121 | position: absolute; 122 | width: 272px; 123 | top: 8px; 124 | left: -300px; 125 | padding: 10px; 126 | border-radius: 5px; 127 | background-color: rgba(255, 255, 255, .6); 128 | transition-property: left; 129 | transition-duration: 500ms; 130 | } 131 | 132 | @media (min-width: 899px) { 133 | .learn-bar { 134 | width: auto; 135 | padding-left: 300px; 136 | } 137 | 138 | .learn-bar > .learn { 139 | left: 8px; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /node_modules/todomvc-common/base.js: -------------------------------------------------------------------------------- 1 | /* global _ */ 2 | (function () { 3 | 'use strict'; 4 | 5 | /* jshint ignore:start */ 6 | // Underscore's Template Module 7 | // Courtesy of underscorejs.org 8 | var _ = (function (_) { 9 | _.defaults = function (object) { 10 | if (!object) { 11 | return object; 12 | } 13 | for (var argsIndex = 1, argsLength = arguments.length; argsIndex < argsLength; argsIndex++) { 14 | var iterable = arguments[argsIndex]; 15 | if (iterable) { 16 | for (var key in iterable) { 17 | if (object[key] == null) { 18 | object[key] = iterable[key]; 19 | } 20 | } 21 | } 22 | } 23 | return object; 24 | } 25 | 26 | // By default, Underscore uses ERB-style template delimiters, change the 27 | // following template settings to use alternative delimiters. 28 | _.templateSettings = { 29 | evaluate : /<%([\s\S]+?)%>/g, 30 | interpolate : /<%=([\s\S]+?)%>/g, 31 | escape : /<%-([\s\S]+?)%>/g 32 | }; 33 | 34 | // When customizing `templateSettings`, if you don't want to define an 35 | // interpolation, evaluation or escaping regex, we need one that is 36 | // guaranteed not to match. 37 | var noMatch = /(.)^/; 38 | 39 | // Certain characters need to be escaped so that they can be put into a 40 | // string literal. 41 | var escapes = { 42 | "'": "'", 43 | '\\': '\\', 44 | '\r': 'r', 45 | '\n': 'n', 46 | '\t': 't', 47 | '\u2028': 'u2028', 48 | '\u2029': 'u2029' 49 | }; 50 | 51 | var escaper = /\\|'|\r|\n|\t|\u2028|\u2029/g; 52 | 53 | // JavaScript micro-templating, similar to John Resig's implementation. 54 | // Underscore templating handles arbitrary delimiters, preserves whitespace, 55 | // and correctly escapes quotes within interpolated code. 56 | _.template = function(text, data, settings) { 57 | var render; 58 | settings = _.defaults({}, settings, _.templateSettings); 59 | 60 | // Combine delimiters into one regular expression via alternation. 61 | var matcher = new RegExp([ 62 | (settings.escape || noMatch).source, 63 | (settings.interpolate || noMatch).source, 64 | (settings.evaluate || noMatch).source 65 | ].join('|') + '|$', 'g'); 66 | 67 | // Compile the template source, escaping string literals appropriately. 68 | var index = 0; 69 | var source = "__p+='"; 70 | text.replace(matcher, function(match, escape, interpolate, evaluate, offset) { 71 | source += text.slice(index, offset) 72 | .replace(escaper, function(match) { return '\\' + escapes[match]; }); 73 | 74 | if (escape) { 75 | source += "'+\n((__t=(" + escape + "))==null?'':_.escape(__t))+\n'"; 76 | } 77 | if (interpolate) { 78 | source += "'+\n((__t=(" + interpolate + "))==null?'':__t)+\n'"; 79 | } 80 | if (evaluate) { 81 | source += "';\n" + evaluate + "\n__p+='"; 82 | } 83 | index = offset + match.length; 84 | return match; 85 | }); 86 | source += "';\n"; 87 | 88 | // If a variable is not specified, place data values in local scope. 89 | if (!settings.variable) source = 'with(obj||{}){\n' + source + '}\n'; 90 | 91 | source = "var __t,__p='',__j=Array.prototype.join," + 92 | "print=function(){__p+=__j.call(arguments,'');};\n" + 93 | source + "return __p;\n"; 94 | 95 | try { 96 | render = new Function(settings.variable || 'obj', '_', source); 97 | } catch (e) { 98 | e.source = source; 99 | throw e; 100 | } 101 | 102 | if (data) return render(data, _); 103 | var template = function(data) { 104 | return render.call(this, data, _); 105 | }; 106 | 107 | // Provide the compiled function source as a convenience for precompilation. 108 | template.source = 'function(' + (settings.variable || 'obj') + '){\n' + source + '}'; 109 | 110 | return template; 111 | }; 112 | 113 | return _; 114 | })({}); 115 | 116 | /* jshint ignore:end */ 117 | 118 | function redirect() { 119 | if (location.hostname === 'tastejs.github.io') { 120 | location.href = location.href.replace('tastejs.github.io/todomvc', 'todomvc.com'); 121 | } 122 | } 123 | 124 | function findRoot() { 125 | var base = location.href.indexOf('examples/'); 126 | return location.href.substr(0, base); 127 | } 128 | 129 | function getFile(file, callback) { 130 | if (!location.host) { 131 | return console.info('Miss the info bar? Run TodoMVC from a server to avoid a cross-origin error.'); 132 | } 133 | 134 | var xhr = new XMLHttpRequest(); 135 | 136 | xhr.open('GET', findRoot() + file, true); 137 | xhr.send(); 138 | 139 | xhr.onload = function () { 140 | if (xhr.status === 200 && callback) { 141 | callback(xhr.responseText); 142 | } 143 | }; 144 | } 145 | 146 | function Learn(learnJSON, config) { 147 | if (!(this instanceof Learn)) { 148 | return new Learn(learnJSON, config); 149 | } 150 | 151 | var template, framework; 152 | 153 | if (typeof learnJSON !== 'object') { 154 | try { 155 | learnJSON = JSON.parse(learnJSON); 156 | } catch (e) { 157 | return; 158 | } 159 | } 160 | 161 | if (config) { 162 | template = config.template; 163 | framework = config.framework; 164 | } 165 | 166 | if (!template && learnJSON.templates) { 167 | template = learnJSON.templates.todomvc; 168 | } 169 | 170 | if (!framework && document.querySelector('[data-framework]')) { 171 | framework = document.querySelector('[data-framework]').dataset.framework; 172 | } 173 | 174 | this.template = template; 175 | 176 | if (learnJSON.backend) { 177 | this.frameworkJSON = learnJSON.backend; 178 | this.frameworkJSON.issueLabel = framework; 179 | this.append({ 180 | backend: true 181 | }); 182 | } else if (learnJSON[framework]) { 183 | this.frameworkJSON = learnJSON[framework]; 184 | this.frameworkJSON.issueLabel = framework; 185 | this.append(); 186 | } 187 | 188 | this.fetchIssueCount(); 189 | } 190 | 191 | Learn.prototype.append = function (opts) { 192 | var aside = document.createElement('aside'); 193 | aside.innerHTML = _.template(this.template, this.frameworkJSON); 194 | aside.className = 'learn'; 195 | 196 | if (opts && opts.backend) { 197 | // Remove demo link 198 | var sourceLinks = aside.querySelector('.source-links'); 199 | var heading = sourceLinks.firstElementChild; 200 | var sourceLink = sourceLinks.lastElementChild; 201 | // Correct link path 202 | var href = sourceLink.getAttribute('href'); 203 | sourceLink.setAttribute('href', href.substr(href.lastIndexOf('http'))); 204 | sourceLinks.innerHTML = heading.outerHTML + sourceLink.outerHTML; 205 | } else { 206 | // Localize demo links 207 | var demoLinks = aside.querySelectorAll('.demo-link'); 208 | Array.prototype.forEach.call(demoLinks, function (demoLink) { 209 | if (demoLink.getAttribute('href').substr(0, 4) !== 'http') { 210 | demoLink.setAttribute('href', findRoot() + demoLink.getAttribute('href')); 211 | } 212 | }); 213 | } 214 | 215 | document.body.className = (document.body.className + ' learn-bar').trim(); 216 | document.body.insertAdjacentHTML('afterBegin', aside.outerHTML); 217 | }; 218 | 219 | Learn.prototype.fetchIssueCount = function () { 220 | var issueLink = document.getElementById('issue-count-link'); 221 | if (issueLink) { 222 | var url = issueLink.href.replace('https://github.com', 'https://api.github.com/repos'); 223 | var xhr = new XMLHttpRequest(); 224 | xhr.open('GET', url, true); 225 | xhr.onload = function (e) { 226 | var parsedResponse = JSON.parse(e.target.responseText); 227 | if (parsedResponse instanceof Array) { 228 | var count = parsedResponse.length; 229 | if (count !== 0) { 230 | issueLink.innerHTML = 'This app has ' + count + ' open issues'; 231 | document.getElementById('issue-count').style.display = 'inline'; 232 | } 233 | } 234 | }; 235 | xhr.send(); 236 | } 237 | }; 238 | 239 | redirect(); 240 | getFile('learn.json', Learn); 241 | })(); 242 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "test": "karma start test/config/karma.conf.js" 5 | }, 6 | "dependencies": { 7 | "angular": "^1.4.3", 8 | "todomvc-common": "^1.0.0", 9 | "todomvc-app-css": "^2.1.0", 10 | "angular-route": "^ 1.4.3", 11 | "angular-resource": "^1.4.3" 12 | }, 13 | "devDependencies": { 14 | "angular-mocks": "^1.3.12", 15 | "karma": "^0.13.0", 16 | "karma-chrome-launcher": "^0.2.2", 17 | "karma-firefox-launcher": "^0.1.7", 18 | "karma-jasmine": "^0.2.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ## How to use 2 | Load up http://todolist.james.am for the candidate. 3 | Tell them "This is an application we've built interally and are planning to use to track basic tasks day to day, we'd like you to give it a quick test (timeboxed to 20 mins) to identify any issues" 4 | 5 | As this is a browser based test, there are a few ways you can suggest they write their findings, such as: 6 | * [Word Processor](https://www.writeurl.com/) 7 | * [Spreadsheet](https://ethercalc.org/) 8 | * [Markdown](https://dillinger.io) 9 | * Old Fashioned Paper 10 | 11 | It's important to note that the test itself is not important, we just use the results of the test as a starting point for many different conversations. 12 | 13 | ### Topics needed to cover: 14 | 15 | * Assumptions made (What should the "Clear" button do? Clear completed? Clear Everything?) 16 | * Who would you clear these assumptions with? 17 | * Field Testing 18 | * Max values (Length) 19 | * Unicode? 20 | * JS Injection? 21 | * DB Injection? (Did they know it doesn't use a DB?) 22 | 23 | * Value of Exploratory vs Script based testing 24 | * Which will result in finding more issues? 25 | * Given limited time, which would you focus on any why? 26 | 27 | * Severity vs Impact of the bugs 28 | * What is Severity and Impact? 29 | * How would you prioritise the list you've written? 30 | * If you only had 5 minutes to retest the application, where would you start? 31 | 32 | * What is written down in a bug report from this example? 33 | * Input Provided 34 | * Expected Output 35 | * Actual Output 36 | * Full steps to reproduce? 37 | * Log files? (if logical error/exception) 38 | * Screenshot? (if visual error) 39 | 40 | ## Bugs still to Create 41 | 42 | * Editing an item to be empty doesn't delete the item. 43 | * Save blank items 44 | 45 | ## Current Bugs 46 | * Not counting the number of items properly (-1) 47 | * Spelling error (toodo) in instruction text at the bottom of the screen 48 | * Active should have a capital 'A' for consistency 49 | * Can't prioritise the tasks / reorder items 50 | * Elements are too pale/small/unreadable 51 | * Undoing "Complete All" takes two clicks 52 | * Spelling Error in default "What need's to be done?" Text (apostrophe) 53 | * Delete Task button has a tooltip of "TODO:REMOVE THIS EVENTUALLY" 54 | * When window is made smaller, everything looks fine except the title overlaps itself 55 | * When a large item is created, the "Complete" button is vertically centered, but the delete button is at the bottom. 56 | * After creating a new item, the "New Item" entry box has 3 spaces in it 57 | * Word Wrapping isn't great - Cuts words apart 58 | * Inappropriate comment in source code. 59 | * Two 404 errors in the console 60 | * Trimmed items will expand to their original state when editing 61 | * Rarely on a refresh, the Symbol à will appear instead of the X and â will appear instead of the "Check All" button (Font failing to load) 62 | * When using two tabs, the list is not kept up to date, you need to manually refresh. 63 | 64 | ## Improvement Suggestions 65 | * Not using HTTPS 66 | * When a large item is created, the "Complete" button is vertically centered, but the delete button is at the bottom. 67 | * Down arrow as "Complete All" is unintuitive 68 | * When adding a new item, page should navigate back to the "All" or "Active" tab to see the new item. 69 | 70 | ## Worthwhile observations 71 | * It trims spaces at the start and the end and only allows a single space elsewhere. 72 | * Item count should probbably be on a page by page basis, not global. 73 | -------------------------------------------------------------------------------- /test/config/karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function (config) { 2 | 'use strict'; 3 | 4 | config.set({ 5 | basePath: '../../', 6 | frameworks: ['jasmine'], 7 | files: [ 8 | 'node_modules/angular/angular.js', 9 | 'node_modules/angular-route/angular-route.js', 10 | 'node_modules/angular-resource/angular-resource.js', 11 | 'node_modules/angular-mocks/angular-mocks.js', 12 | 'js/**/*.js', 13 | 'test/unit/**/*.js' 14 | ], 15 | autoWatch: true, 16 | singleRun: false, 17 | browsers: ['Chrome', 'Firefox'] 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /test/unit/directivesSpec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, beforeEach, inject, expect, angular*/ 2 | (function () { 3 | 'use strict'; 4 | 5 | beforeEach(module('todomvc')); 6 | 7 | describe('todoFocus directive', function () { 8 | var scope, compile, browser; 9 | 10 | beforeEach(inject(function ($rootScope, $compile, $browser) { 11 | scope = $rootScope.$new(); 12 | compile = $compile; 13 | browser = $browser; 14 | })); 15 | 16 | it('should focus on truthy expression', function () { 17 | var el = angular.element(''); 18 | scope.focus = false; 19 | 20 | compile(el)(scope); 21 | expect(browser.deferredFns.length).toBe(0); 22 | 23 | scope.$apply(function () { 24 | scope.focus = true; 25 | }); 26 | 27 | expect(browser.deferredFns.length).toBe(1); 28 | }); 29 | }); 30 | }()); 31 | -------------------------------------------------------------------------------- /test/unit/todoCtrlSpec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, beforeEach, inject, expect*/ 2 | (function () { 3 | 'use strict'; 4 | 5 | describe('Todo Controller', function () { 6 | var ctrl, scope, store; 7 | 8 | // Load the module containing the app, only 'ng' is loaded by default. 9 | beforeEach(module('todomvc')); 10 | 11 | beforeEach(inject(function ($controller, $rootScope, localStorage) { 12 | scope = $rootScope.$new(); 13 | 14 | store = localStorage; 15 | 16 | localStorage.todos = []; 17 | localStorage._getFromLocalStorage = function () { 18 | return []; 19 | }; 20 | localStorage._saveToLocalStorage = function (todos) { 21 | localStorage.todos = todos; 22 | }; 23 | 24 | ctrl = $controller('TodoCtrl', { 25 | $scope: scope, 26 | store: store 27 | }); 28 | })); 29 | 30 | it('should not have an edited Todo on start', function () { 31 | expect(scope.editedTodo).toBeNull(); 32 | }); 33 | 34 | it('should not have any Todos on start', function () { 35 | expect(scope.todos.length).toBe(0); 36 | }); 37 | 38 | it('should have all Todos completed', function () { 39 | scope.$digest(); 40 | expect(scope.allChecked).toBeTruthy(); 41 | }); 42 | 43 | describe('the filter', function () { 44 | it('should default to ""', function () { 45 | scope.$emit('$routeChangeSuccess'); 46 | 47 | expect(scope.status).toBe(''); 48 | expect(scope.statusFilter).toEqual({}); 49 | }); 50 | 51 | describe('being at /active', function () { 52 | it('should filter non-completed', inject(function ($controller) { 53 | ctrl = $controller('TodoCtrl', { 54 | $scope: scope, 55 | store: store, 56 | $routeParams: { 57 | status: 'active' 58 | } 59 | }); 60 | 61 | scope.$emit('$routeChangeSuccess'); 62 | expect(scope.statusFilter.completed).toBeFalsy(); 63 | })); 64 | }); 65 | 66 | describe('being at /completed', function () { 67 | it('should filter completed', inject(function ($controller) { 68 | ctrl = $controller('TodoCtrl', { 69 | $scope: scope, 70 | $routeParams: { 71 | status: 'completed' 72 | }, 73 | store: store 74 | }); 75 | 76 | scope.$emit('$routeChangeSuccess'); 77 | expect(scope.statusFilter.completed).toBeTruthy(); 78 | })); 79 | }); 80 | }); 81 | 82 | describe('having no Todos', function () { 83 | var ctrl; 84 | 85 | beforeEach(inject(function ($controller) { 86 | ctrl = $controller('TodoCtrl', { 87 | $scope: scope, 88 | store: store 89 | }); 90 | scope.$digest(); 91 | })); 92 | 93 | it('should not add empty Todos', function () { 94 | scope.newTodo = ''; 95 | scope.addTodo(); 96 | scope.$digest(); 97 | expect(scope.todos.length).toBe(0); 98 | }); 99 | 100 | it('should not add items consisting only of whitespaces', function () { 101 | scope.newTodo = ' '; 102 | scope.addTodo(); 103 | scope.$digest(); 104 | expect(scope.todos.length).toBe(0); 105 | }); 106 | 107 | 108 | it('should trim whitespace from new Todos', function () { 109 | scope.newTodo = ' buy some unicorns '; 110 | scope.addTodo(); 111 | scope.$digest(); 112 | expect(scope.todos.length).toBe(1); 113 | expect(scope.todos[0].title).toBe('buy some unicorns'); 114 | }); 115 | }); 116 | 117 | describe('having some saved Todos', function () { 118 | var ctrl; 119 | 120 | beforeEach(inject(function ($controller) { 121 | ctrl = $controller('TodoCtrl', { 122 | $scope: scope, 123 | store: store 124 | }); 125 | 126 | store.insert({ title: 'Uncompleted Item 0', completed: false }); 127 | store.insert({ title: 'Uncompleted Item 1', completed: false }); 128 | store.insert({ title: 'Uncompleted Item 2', completed: false }); 129 | store.insert({ title: 'Completed Item 0', completed: true }); 130 | store.insert({ title: 'Completed Item 1', completed: true }); 131 | scope.$digest(); 132 | })); 133 | 134 | it('should count Todos correctly', function () { 135 | expect(scope.todos.length).toBe(5); 136 | expect(scope.remainingCount).toBe(3); 137 | expect(scope.completedCount).toBe(2); 138 | expect(scope.allChecked).toBeFalsy(); 139 | }); 140 | 141 | it('should save Todos to local storage', function () { 142 | expect(scope.todos.length).toBe(5); 143 | }); 144 | 145 | it('should remove Todos w/o title on saving', function () { 146 | var todo = store.todos[2]; 147 | scope.editTodo(todo); 148 | todo.title = ''; 149 | scope.saveEdits(todo); 150 | expect(scope.todos.length).toBe(4); 151 | }); 152 | 153 | it('should trim Todos on saving', function () { 154 | var todo = store.todos[0]; 155 | scope.editTodo(todo); 156 | todo.title = ' buy moar unicorns '; 157 | scope.saveEdits(todo); 158 | expect(scope.todos[0].title).toBe('buy moar unicorns'); 159 | }); 160 | 161 | it('clearCompletedTodos() should clear completed Todos', function () { 162 | scope.clearCompletedTodos(); 163 | expect(scope.todos.length).toBe(3); 164 | }); 165 | 166 | it('markAll() should mark all Todos completed', function () { 167 | scope.markAll(true); 168 | scope.$digest(); 169 | expect(scope.completedCount).toBe(5); 170 | }); 171 | 172 | it('revertTodo() get a Todo to its previous state', function () { 173 | var todo = store.todos[0]; 174 | scope.editTodo(todo); 175 | todo.title = 'Unicorn sparkly skypuffles.'; 176 | scope.revertEdits(todo); 177 | scope.$digest(); 178 | expect(scope.todos[0].title).toBe('Uncompleted Item 0'); 179 | }); 180 | }); 181 | }); 182 | }()); 183 | --------------------------------------------------------------------------------