├── .gitignore ├── .jshintrc ├── .travis.yml ├── LICENSE ├── README.md ├── bower.json ├── dist ├── angular-pickadate.css ├── angular-pickadate.js └── angular-pickadate.min.js ├── gulpfile.js ├── karma.conf.js ├── package.json ├── src ├── angular-pickadate.js └── angular-pickadate.scss └── test ├── angular-pickadate.spec.js ├── lib ├── angular-1.2.21.js ├── angular-1.3.6.js ├── angular-1.4.0.js ├── angular-mocks-1.2.21.js ├── angular-mocks-1.3.6.js ├── angular-mocks-1.4.0.js └── browser_trigger.js └── pickadate-date_helper.spec.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | // JSHint Default Configuration File (as on JSHint website) 3 | // See http://jshint.com/docs/ for more details 4 | 5 | "maxerr" : 50, // {int} Maximum error before stopping 6 | 7 | // Enforcing 8 | "bitwise" : true, // true: Prohibit bitwise operators (&, |, ^, etc.) 9 | "camelcase" : true, // true: Identifiers must be in camelCase 10 | "curly" : false, // true: Require {} for every new block or scope 11 | "eqeqeq" : true, // true: Require triple equals (===) for comparison 12 | "forin" : true, // true: Require filtering for..in loops with obj.hasOwnProperty() 13 | "immed" : false, // true: Require immediate invocations to be wrapped in parens e.g. `(function () { } ());` 14 | "indent" : 2, // {int} Number of spaces to use for indentation 15 | "latedef" : false, // true: Require variables/functions to be defined before being used 16 | "newcap" : false, // true: Require capitalization of all constructor functions e.g. `new F()` 17 | "noarg" : true, // true: Prohibit use of `arguments.caller` and `arguments.callee` 18 | "noempty" : true, // true: Prohibit use of empty blocks 19 | "nonew" : false, // true: Prohibit use of constructors for side-effects (without assignment) 20 | "plusplus" : false, // true: Prohibit use of `++` & `--` 21 | "quotmark" : false, // Quotation mark consistency: 22 | // false : do nothing (default) 23 | // true : ensure whatever is used is consistent 24 | // "single" : require single quotes 25 | // "double" : require double quotes 26 | "undef" : true, // true: Require all non-global variables to be declared (prevents global leaks) 27 | "unused" : true, // true: Require all defined variables be used 28 | "strict" : true, // true: Requires all functions run in ES5 Strict Mode 29 | "maxparams" : false, // {int} Max number of formal params allowed per function 30 | "maxdepth" : false, // {int} Max depth of nested blocks (within functions) 31 | "maxstatements" : false, // {int} Max number statements per function 32 | "maxcomplexity" : false, // {int} Max cyclomatic complexity per function 33 | "maxlen" : false, // {int} Max number of characters per line 34 | 35 | // Relaxing 36 | "asi" : false, // true: Tolerate Automatic Semicolon Insertion (no semicolons) 37 | "boss" : false, // true: Tolerate assignments where comparisons would be expected 38 | "debug" : false, // true: Allow debugger statements e.g. browser breakpoints. 39 | "eqnull" : false, // true: Tolerate use of `== null` 40 | "es5" : false, // true: Allow ES5 syntax (ex: getters and setters) 41 | "esnext" : false, // true: Allow ES.next (ES6) syntax (ex: `const`) 42 | "moz" : false, // true: Allow Mozilla specific syntax (extends and overrides esnext features) 43 | // (ex: `for each`, multiple try/catch, function expression…) 44 | "evil" : false, // true: Tolerate use of `eval` and `new Function()` 45 | "expr" : false, // true: Tolerate `ExpressionStatement` as Programs 46 | "funcscope" : false, // true: Tolerate defining variables inside control statements 47 | "globalstrict" : false, // true: Allow global "use strict" (also enables 'strict') 48 | "iterator" : false, // true: Tolerate using the `__iterator__` property 49 | "lastsemic" : false, // true: Tolerate omitting a semicolon for the last statement of a 1-line block 50 | "laxbreak" : false, // true: Tolerate possibly unsafe line breakings 51 | "laxcomma" : false, // true: Tolerate comma-first style coding 52 | "loopfunc" : false, // true: Tolerate functions being defined in loops 53 | "multistr" : false, // true: Tolerate multi-line strings 54 | "proto" : false, // true: Tolerate using the `__proto__` property 55 | "scripturl" : false, // true: Tolerate script-targeted URLs 56 | "shadow" : false, // true: Allows re-define variables later in code e.g. `var x=1; x=2;` 57 | "sub" : false, // true: Tolerate using `[]` notation when it can still be expressed in dot notation 58 | "supernew" : false, // true: Tolerate `new function () { ... };` and `new Object;` 59 | "validthis" : false, // true: Tolerate using this in a non-constructor function 60 | 61 | // Environments 62 | "browser" : true, // Web Browser (window, document, etc) 63 | "browserify" : false, // Browserify (node.js code in the browser) 64 | "couch" : false, // CouchDB 65 | "devel" : true, // Development/debugging (alert, confirm, etc) 66 | "dojo" : false, // Dojo Toolkit 67 | "jquery" : true, // jQuery 68 | "mootools" : false, // MooTools 69 | "node" : false, // Node.js 70 | "nonstandard" : false, // Widely adopted globals (escape, unescape, etc) 71 | "prototypejs" : false, // Prototype and Scriptaculous 72 | "rhino" : false, // Rhino 73 | "worker" : false, // Web Workers 74 | "wsh" : false, // Windows Scripting Host 75 | "yui" : false, // Yahoo User Interface 76 | 77 | // Custom Globals 78 | "globals" : { 79 | "describe" : true, 80 | "beforeEach" : true, 81 | "afterEach" : true, 82 | "inject" : true, 83 | "it" : true, 84 | "angular" : true, 85 | "module" : true, 86 | "expect" : true, 87 | "browserTrigger" : true, 88 | "sinon" : true 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '0.10' 4 | 5 | script: 6 | - npm test 7 | 8 | sudo: false 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Restorando 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # angular-pickadate [![Build Status](https://travis-ci.org/restorando/angular-pickadate.svg?branch=master)](https://travis-ci.org/restorando/angular-pickadate) 2 | 3 | 4 | A simple and fluid inline datepicker for AngularJS with no extra dependencies. 5 | 6 | ![pickadate](http://img.ctrlv.in/img/5294e96436552.jpg) 7 | 8 | ### Demo 9 | 10 | View demo in a new window 11 | 12 | ### Installation 13 | 14 | 1) Add the `pickadate` module to your dependencies 15 | 16 | ```javascript 17 | angular.module('myApp', ['pickadate']); 18 | ``` 19 | 20 | 2) Use the `pickadate` directive in any element 21 | 22 | ```html 23 |
24 | ``` 25 | 26 | If the element is an ``, it will display the datepicker as a modal. Otherwise, it will be rendered inline. 27 | 28 | Pickadate is fluid, so it will take the width of the parent container. 29 | 30 | ### Pickadate options 31 | 32 | #### format 33 | 34 | You can specify the date format using the `format` attribute. Supported formats must have the year, month and day parts, and the separator must be `-` or `/`. 35 | 36 | ```html 37 |
38 | ``` 39 | 40 | Format string can be composed of the following elements: 41 | 42 | * `'yyyy;`: 4 digit representation of year (e.g. AD 1 => 0001, AD 2010 => 2010) 43 | * `'mm'` or `'MM'`: Month in year, padded (01-12) 44 | * `'dd'`: Day in month, padded (01-31) 45 | 46 | Every option that receives a date as the input (e.g. min-date, max-date, etc) should be entered using the same format. 47 | 48 | #### min-date, max-date 49 | 50 | ```html 51 |
52 | ``` 53 | 54 | ```javascript 55 | function MyAppController($scope) { 56 | $scope.minDate = '2013-11-10'; 57 | $scope.maxDate = '2013-12-31'; 58 | } 59 | ``` 60 | 61 | `min-date` and `max-date` take angular expressions, so if you want to specify the values inline, don't forget the quotes! 62 | 63 | ```html 64 |
65 | ``` 66 | 67 | #### disabled-dates 68 | 69 | You can specify a function that will determine if a date is disabled or not. You can pass `formattedDate` as an argument to receive the date formatted in the current locale, or `date` to receive the date object. You can pass both of them if you need. 70 | 71 | ```html 72 |
73 | ``` 74 | 75 | ```javascript 76 | function MyAppController($scope) { 77 | $scope.disabledDatesFn = function(formattedDate) { 78 | return ['2013-11-10', '2013-11-15', '2013-11-19'].indexOf(formattedDate) > -1; 79 | } 80 | } 81 | ``` 82 | 83 | This is handy if you want to disable dates programatically. 84 | 85 | ```html 86 |
87 | ``` 88 | 89 | ```javascript 90 | function MyAppController($scope) { 91 | $scope.disabledDatesFn = function(date) { 92 | return date.getDay() === 6; // Disable every Sunday 93 | } 94 | } 95 | ``` 96 | 97 | #### default-date 98 | 99 | Allows you to preset the calendar to a particular month without setting the chosen date. 100 | 101 | ```html 102 |
103 | ``` 104 | 105 | ```javascript 106 | function MyAppController($scope) { 107 | $scope.presetDate = '2013-12-01'; 108 | } 109 | ``` 110 | 111 | #### week-starts-on 112 | 113 | Sets the first day of the week. The default is 0 for Sunday. 114 | 115 | ```html 116 |
117 | ``` 118 | 119 | #### no-extra-rows 120 | 121 | The calendar will have between 4 and 6 rows if this attribute is present. By default it will always have 6 rows. 122 | 123 | ```html 124 |
125 | ``` 126 | 127 | #### multiple 128 | 129 | The calendar will support selecting multiple dates. NgModel will be set as an array of date strings 130 | 131 | ```html 132 |
133 | ``` 134 | 135 | #### allow-blank-date 136 | 137 | The calendar will allow user to remove the last remaining date. 138 | 139 | ```html 140 |
141 | ``` 142 | 143 | #### select-other-months 144 | 145 | This attribute can take the following string values: 146 | 147 | - `next`: the calendar will allow selecting dates from the next month 148 | - `previous`: same as the one before, but for previous month dates 149 | - `both`: the calendar will allow selecting dates from both the next and previous month 150 | 151 | ```html 152 |
153 | ``` 154 | 155 | ### I18n & Icons 156 | 157 | Pickadate uses angular `$locale` module for the date translations. If you want to have the calendar in any other language, please include the corresponding AngularJS i18n files. You can get them here: [https://code.angularjs.org/1.3.0/i18n/](https://code.angularjs.org/1.3.0/i18n/). 158 | 159 | For the remaining translations you can configure the `pickadateI18nProvider`. 160 | 161 | ```javascript 162 | angular.module('testApp', ['pickadate']) 163 | 164 | .config(function(pickadateI18nProvider) { 165 | pickadateI18nProvider.translations = { 166 | prev: ' ant', 167 | next: 'sig ' 168 | } 169 | }); 170 | ``` 171 | 172 | The translations can contain custom html code, useful to include custom icons in the calendar controls. 173 | 174 | ## License 175 | 176 | Copyright (c) 2013 Restorando 177 | 178 | MIT License 179 | 180 | Permission is hereby granted, free of charge, to any person obtaining 181 | a copy of this software and associated documentation files (the 182 | "Software"), to deal in the Software without restriction, including 183 | without limitation the rights to use, copy, modify, merge, publish, 184 | distribute, sublicense, and/or sell copies of the Software, and to 185 | permit persons to whom the Software is furnished to do so, subject to 186 | the following conditions: 187 | 188 | The above copyright notice and this permission notice shall be 189 | included in all copies or substantial portions of the Software. 190 | 191 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 192 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 193 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 194 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 195 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 196 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 197 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 198 | 199 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-pickadate", 3 | "version": "2.0.2", 4 | "homepage": "https://github.com/restorando/angular-pickadate", 5 | "authors": [ 6 | "Gabriel Schammah " 7 | ], 8 | "description": "A simple and fluid inline datepicker for AngularJS with no extra dependencies", 9 | "keywords": [ 10 | "datepicker", 11 | "angular", 12 | "angularjs", 13 | "pickadate", 14 | "date" 15 | ], 16 | "main": [ 17 | "dist/angular-pickadate.js", 18 | "dist/angular-pickadate.css" 19 | ], 20 | "dependencies": { 21 | "angular": "1.x" 22 | }, 23 | "license": "MIT", 24 | "ignore": [ 25 | "**/.*", 26 | "node_modules", 27 | "bower_components", 28 | "test", 29 | "example", 30 | "package.json", 31 | "gulpfile.js", 32 | "karma.conf.js", 33 | "LICENSE" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /dist/angular-pickadate.css: -------------------------------------------------------------------------------- 1 | .pickadate { 2 | font-family: 'Helvetica Neue', Helvetica, Helvetica, Arial, sans-serif; } 3 | .pickadate a { 4 | color: #666666; } 5 | .pickadate a:visited { 6 | color: #666666; } 7 | 8 | .pickadate-header { 9 | position: relative; } 10 | 11 | .pickadate-main { 12 | margin: 0; 13 | padding: 0; 14 | width: 100%; 15 | text-align: center; 16 | font-size: 12px; } 17 | 18 | .pickadate-cell { 19 | overflow: hidden; 20 | margin: 0; 21 | padding: 0; } 22 | .pickadate-cell li { 23 | display: block; 24 | float: left; 25 | border: 1px solid #DCDCDC; 26 | border-width: 0 1px 1px 0; 27 | width: 14.285%; 28 | padding: 1.3% 0 1.3% 0; 29 | line-height: normal; 30 | box-sizing: border-box; 31 | -moz-box-sizing: border-box; 32 | -webkit-box-sizing: border-box; } 33 | .pickadate-cell li:nth-child(7n+0) { 34 | border-right: 1px solid #DCDCDC; } 35 | .pickadate-cell li:nth-child(1), .pickadate-cell li:nth-child(8), .pickadate-cell li:nth-child(15), .pickadate-cell li:nth-child(22), .pickadate-cell li:nth-child(29), .pickadate-cell li:nth-child(36) { 36 | border-left: 1px solid #DCDCDC; } 37 | .pickadate-cell .pickadate-disabled { 38 | color: #DCDCDC; } 39 | .pickadate-cell .pickadate-disabled a { 40 | color: #DCDCDC; } 41 | .pickadate-cell .pickadate-enabled { 42 | cursor: pointer; 43 | font-size: 12px; 44 | color: #666666; } 45 | .pickadate-cell .pickadate-today { 46 | background-color: #eaeaea; } 47 | .pickadate-cell .pickadate-active { 48 | background-color: #b52a00; 49 | color: white; } 50 | .pickadate-cell .pickadate-head { 51 | border-top: 1px solid #DCDCDC; 52 | background: #f3f3f3; } 53 | .pickadate-cell .pickadate-head:nth-child(1), .pickadate-cell .pickadate-head:nth-child(7) { 54 | background: #f3f3f3; } 55 | 56 | .pickadate-centered-heading { 57 | font-weight: normal; 58 | text-align: center; 59 | font-size: 1em; 60 | margin: 13px 0 13px 0; 61 | line-height: normal; } 62 | 63 | .pickadate-controls { 64 | position: absolute; 65 | z-index: 10; 66 | width: 100%; } 67 | .pickadate-controls .pickadate-next { 68 | float: right; } 69 | .pickadate-controls a { 70 | text-decoration: none; 71 | font-size: 0.9em; } 72 | 73 | .pickadate-modal { 74 | position: absolute; 75 | background-color: #fff; 76 | width: 300px; 77 | border: 1px solid #ccc; 78 | border-radius: 4px; 79 | padding: 0 5px 5px 5px; 80 | z-index: 1000; } 81 | -------------------------------------------------------------------------------- /dist/angular-pickadate.js: -------------------------------------------------------------------------------- 1 | ;(function(angular){ 2 | 'use strict'; 3 | var indexOf = [].indexOf || function(item) { 4 | for (var i = 0, l = this.length; i < l; i++) { 5 | if (i in this && this[i] === item) return i; 6 | } 7 | return -1; 8 | }; 9 | 10 | function map(items, property) { 11 | var mappedArray = []; 12 | angular.forEach(items, function(item) { 13 | mappedArray.push(angular.isFunction(property) ? property(item) : item[property]); 14 | }); 15 | return mappedArray; 16 | } 17 | 18 | angular.module('pickadate', []) 19 | 20 | .provider('pickadateI18n', function() { 21 | var defaults = { 22 | 'prev': 'prev', 23 | 'next': 'next' 24 | }; 25 | 26 | this.translations = {}; 27 | 28 | this.$get = function() { 29 | var translations = this.translations; 30 | 31 | return { 32 | t: function(key) { 33 | return translations[key] || defaults[key]; 34 | } 35 | }; 36 | }; 37 | }) 38 | 39 | .factory('pickadateModalBindings', ['$window', '$document', function($window, $document) { 40 | var supportPageOffset = $window.pageXOffset !== undefined, 41 | isCSS1Compat = (($document.compatMode || "") === "CSS1Compat"); 42 | 43 | var computeStyles = function(element) { 44 | var scrollX = supportPageOffset ? $window.pageXOffset : isCSS1Compat ? $document.documentElement.scrollLeft : $document.body.scrollLeft, 45 | scrollY = supportPageOffset ? $window.pageYOffset : isCSS1Compat ? $document.documentElement.scrollTop : $document.body.scrollTop, 46 | innerWidth = $window.innerWidth || $document.documentElement.clientWidth || $document.body.clientWidth, 47 | styles = { top: scrollY + element.getBoundingClientRect().bottom + 'px' }; 48 | 49 | if ((innerWidth - element.getBoundingClientRect().left ) >= 300) { 50 | styles.left = scrollX + element.getBoundingClientRect().left + 'px'; 51 | } else { 52 | styles.right = innerWidth - element.getBoundingClientRect().right - scrollX + 'px'; 53 | } 54 | 55 | return styles; 56 | }; 57 | 58 | var isDescendant = function(parent, child) { 59 | var node = child.parentNode; 60 | while (node !== null) { 61 | if (node === parent) return true; 62 | node = node.parentNode; 63 | } 64 | return false; 65 | }; 66 | 67 | return function(scope, element, rootNode) { 68 | var togglePicker = function(toggle) { 69 | scope.displayPicker = toggle; 70 | scope.$apply(); 71 | }; 72 | 73 | element.on('focus', function() { 74 | scope.modalStyles = computeStyles(element[0]); 75 | togglePicker(true); 76 | }); 77 | 78 | element.on('keydown', function(e) { 79 | if (indexOf.call([9, 13, 27], e.keyCode) >= 0) togglePicker(false); 80 | }); 81 | 82 | $document.on('click', function(e) { 83 | if (isDescendant(rootNode, e.target) || e.target === element[0]) return; 84 | togglePicker(false); 85 | }); 86 | }; 87 | 88 | }]) 89 | 90 | .factory('pickadateDateHelper', ['$locale', 'dateFilter', function($locale, dateFilter) { 91 | 92 | function getPartName(part) { 93 | switch (part) { 94 | case 'dd': return 'day'; 95 | case 'MM': return 'month'; 96 | case 'yyyy': return 'year'; 97 | } 98 | } 99 | 100 | return function(format, options) { 101 | var minDate, maxDate, disabledDates, currentDate, weekStartsOn, noExtraRows; 102 | 103 | options = options || {}; 104 | format = format || 'yyyy-MM-dd'; 105 | weekStartsOn = options.weekStartsOn; 106 | noExtraRows = options.noExtraRows; 107 | disabledDates = options.disabledDates || angular.noop; 108 | 109 | if (!angular.isNumber(weekStartsOn) || weekStartsOn < 0 || weekStartsOn > 6) weekStartsOn = 0; 110 | 111 | return { 112 | 113 | parseDate: function(dateString) { 114 | if (!dateString) return; 115 | if (angular.isDate(dateString)) return new Date(dateString); 116 | 117 | var formatRegex = '(dd|MM|yyyy)', 118 | separator = format.match(/[-|/]/)[0], 119 | dateParts = dateString.split(separator), 120 | regexp = new RegExp([formatRegex, formatRegex, formatRegex].join(separator)), 121 | formatParts = format.match(regexp), 122 | dateObj = {}; 123 | 124 | formatParts.shift(); 125 | 126 | angular.forEach(formatParts, function(part, i) { 127 | dateObj[getPartName(part)] = parseInt(dateParts[i], 10); 128 | }); 129 | 130 | if (isNaN(dateObj.year) || isNaN(dateObj.month) || isNaN(dateObj.day)) return; 131 | 132 | return new Date(dateObj.year, dateObj.month - 1, dateObj.day, 3); 133 | }, 134 | 135 | setRestrictions: function(restrictions) { 136 | minDate = this.parseDate(restrictions.minDate) || new Date(0); 137 | maxDate = this.parseDate(restrictions.maxDate) || new Date(99999999999999); 138 | currentDate = restrictions.currentDate; 139 | }, 140 | 141 | allowPrevMonth: function() { 142 | return currentDate > minDate; 143 | }, 144 | 145 | allowNextMonth: function() { 146 | var nextMonth = angular.copy(currentDate); 147 | nextMonth.setMonth(nextMonth.getMonth() + 1); 148 | return nextMonth <= maxDate; 149 | }, 150 | 151 | buildDateObject: function(date) { 152 | var localDate = angular.copy(date), 153 | formattedDate = dateFilter(localDate, format), 154 | disabled = disabledDates({date: localDate, formattedDate: formattedDate}), 155 | monthOffset = this.getMonthOffset(localDate, currentDate), 156 | outOfMinRange = localDate < minDate, 157 | outOfMaxRange = localDate > maxDate, 158 | outOfMonth = (monthOffset === -1 && !options.previousMonthSelectable) || 159 | (monthOffset === 1 && !options.nextMonthSelectable); 160 | 161 | return { 162 | date: localDate, 163 | formattedDate: formattedDate, 164 | today: formattedDate === dateFilter(new Date(), format), 165 | disabled: disabled, 166 | outOfMinRange: outOfMinRange, 167 | outOfMaxRange: outOfMaxRange, 168 | monthOffset: monthOffset, 169 | enabled: !(disabled || outOfMinRange || outOfMaxRange || outOfMonth) 170 | }; 171 | }, 172 | 173 | buildDates: function(year, month, options) { 174 | var dates = [], 175 | date = new Date(year, month, 1, 3), 176 | lastDate = new Date(year, month + 1, 0, 3); 177 | 178 | options = options || {}; 179 | currentDate = angular.copy(date); 180 | 181 | while (date.getDay() !== weekStartsOn) date.setDate(date.getDate() - 1); 182 | 183 | for (var i = 0; i < 42; i++) { // 42 == 6 rows of dates 184 | if (noExtraRows && date.getDay() === weekStartsOn && date > lastDate) break; 185 | 186 | dates.push(this.buildDateObject(date)); 187 | date.setDate(date.getDate() + 1); 188 | } 189 | 190 | return dates; 191 | }, 192 | 193 | buildDayNames: function() { 194 | var dayNames = $locale.DATETIME_FORMATS.SHORTDAY; 195 | 196 | if (weekStartsOn) { 197 | dayNames = dayNames.slice(0); 198 | for (var i = 0; i < weekStartsOn; i++) dayNames.push(dayNames.shift()); 199 | } 200 | return dayNames; 201 | }, 202 | 203 | getMonthOffset: function(date1, date2) { 204 | return date1.getMonth() - date2.getMonth() + (12 * (date1.getFullYear() - date2.getFullYear())); 205 | } 206 | }; 207 | }; 208 | 209 | }]) 210 | 211 | .directive('pickadate', ['$locale', '$sce', '$compile', '$document', '$window', 'pickadateDateHelper', 212 | 'pickadateI18n', 'pickadateModalBindings', 'filterFilter', function($locale, $sce, $compile, $document, $window, 213 | dateHelperFactory, i18n, modalBindings, filter) { 214 | 215 | var TEMPLATE = 216 | '
' + 217 | '
' + 218 | ''+ 226 | '

' + 227 | '{{currentDate | date:"MMMM yyyy"}}' + 228 | '

' + 229 | '
' + 230 | '
' + 231 | '
' + 232 | '
    ' + 233 | '
  • ' + 234 | '{{dayName}}' + 235 | '
  • ' + 236 | '
' + 237 | '
    ' + 238 | '
  • ' + 239 | '{{dateObj.date | date:"d"}}' + 240 | '
  • ' + 241 | '
' + 242 | '
' + 243 | '
' + 244 | '
'; 245 | 246 | return { 247 | require: 'ngModel', 248 | scope: { 249 | defaultDate: '=', 250 | minDate: '=', 251 | maxDate: '=', 252 | disabledDates: '&', 253 | weekStartsOn: '=' 254 | }, 255 | 256 | link: function(scope, element, attrs, ngModel) { 257 | var allowMultiple = attrs.hasOwnProperty('multiple'), 258 | allowBlank = attrs.hasOwnProperty('allowBlankDate'), 259 | selectedDates = [], 260 | wantsModal = element[0] instanceof HTMLInputElement, 261 | compiledHtml = $compile(TEMPLATE)(scope), 262 | format = (attrs.format || 'yyyy-MM-dd').replace(/m/g, 'M'), 263 | dateHelper = dateHelperFactory(format, { 264 | previousMonthSelectable: /^(previous|both)$/.test(attrs.selectOtherMonths), 265 | nextMonthSelectable: /^(next|both)$/.test(attrs.selectOtherMonths), 266 | weekStartsOn: scope.weekStartsOn, 267 | noExtraRows: attrs.hasOwnProperty('noExtraRows'), 268 | disabledDates: scope.disabledDates 269 | }); 270 | 271 | scope.displayPicker = !wantsModal; 272 | 273 | scope.setDate = function(dateObj) { 274 | if (!dateObj.enabled) return; 275 | 276 | selectedDates = toggleDate(dateObj, selectedDates); 277 | 278 | setViewValue(selectedDates); 279 | 280 | scope.changeMonth(dateObj.monthOffset); 281 | scope.displayPicker = !wantsModal; 282 | }; 283 | 284 | var $render = ngModel.$render = function(options) { 285 | if (angular.isArray(ngModel.$viewValue)) { 286 | selectedDates = ngModel.$viewValue; 287 | } else if (ngModel.$viewValue) { 288 | selectedDates = [ngModel.$viewValue]; 289 | } 290 | 291 | scope.currentDate = dateHelper.parseDate(scope.defaultDate || selectedDates[0]) || new Date(); 292 | 293 | dateHelper.setRestrictions(scope); 294 | 295 | selectedDates = map(selectedDates, function(date) { 296 | return dateHelper.buildDateObject(dateHelper.parseDate(date)); 297 | }); 298 | 299 | selectedDates = filter(selectedDates, { enabled: true }); 300 | 301 | setViewValue(selectedDates, options); 302 | render(); 303 | }; 304 | 305 | scope.classesFor = function(date) { 306 | var formattedDates = map(selectedDates, 'formattedDate'), 307 | classes = indexOf.call(formattedDates, date.formattedDate) >= 0 ? 'pickadate-active' : null; 308 | return date.classNames.concat(classes); 309 | }; 310 | 311 | scope.changeMonth = function(offset) { 312 | if (!offset) return; 313 | // If the current date is January 31th, setting the month to date.getMonth() + 1 314 | // sets the date to March the 3rd, since the date object adds 30 days to the current 315 | // date. Settings the date to the 2nd day of the month is a workaround to prevent this 316 | // behaviour 317 | scope.currentDate.setDate(1); 318 | scope.currentDate.setMonth(scope.currentDate.getMonth() + offset); 319 | render(); 320 | }; 321 | 322 | // Workaround to watch multiple properties. XXX use $scope.$watchGroup in angular 1.3 323 | scope.$watch(function() { 324 | return angular.toJson([scope.minDate, scope.maxDate]); 325 | }, $render); 326 | 327 | // Insert datepicker into DOM 328 | if (wantsModal) { 329 | modalBindings(scope, element, compiledHtml[0]); 330 | 331 | // if the user types a date, update the picker and set validity 332 | scope.$watch(function() { 333 | return ngModel.$viewValue; 334 | }, function(val) { 335 | var isValidDate = dateHelper.parseDate(val); 336 | 337 | if (isValidDate) $render({ skipRenderInput: true }); 338 | ngModel.$setValidity('date', !!isValidDate); 339 | }); 340 | 341 | // if the input element has a value, set it as the ng-model 342 | scope.$$postDigest(function() { 343 | if (attrs.value) { ngModel.$viewValue = attrs.value; $render(); } 344 | }); 345 | 346 | element.after(compiledHtml.addClass('pickadate-modal')); 347 | } else { 348 | element.append(compiledHtml); 349 | } 350 | 351 | function render() { 352 | var dates = dateHelper.buildDates(scope.currentDate.getFullYear(), scope.currentDate.getMonth()); 353 | 354 | scope.allowPrevMonth = dateHelper.allowPrevMonth(); 355 | scope.allowNextMonth = dateHelper.allowNextMonth(); 356 | scope.dayNames = dateHelper.buildDayNames(); 357 | 358 | scope.dates = map(dates, function(date) { 359 | date.classNames = [date.enabled ? 'pickadate-enabled' : 'pickadate-disabled']; 360 | 361 | if (date.today) date.classNames.push('pickadate-today'); 362 | if (date.disabled) date.classNames.push('pickadate-unavailable'); 363 | 364 | return date; 365 | }); 366 | } 367 | 368 | function setViewValue(value, options) { 369 | options = options || {}; 370 | 371 | if (allowMultiple) { 372 | ngModel.$setViewValue(map(value, 'formattedDate')); 373 | } else { 374 | ngModel.$setViewValue(value[0] && value[0].formattedDate); 375 | } 376 | 377 | if (!options.skipRenderInput) element.val(ngModel.$viewValue); 378 | } 379 | 380 | function toggleDate(dateObj, dateArray) { 381 | var index = indexOf.call(map(dateArray, 'formattedDate'), dateObj.formattedDate); 382 | if (index === -1) { 383 | dateArray = addDate(dateObj, dateArray); 384 | } else if (allowBlank || (dateArray.length > 1)) { 385 | dateArray.splice(index, 1); 386 | } 387 | return dateArray; 388 | } 389 | 390 | function addDate(dateObj, dateArray){ 391 | if (allowMultiple) { 392 | dateArray.push(dateObj); 393 | } else { 394 | dateArray = [dateObj]; 395 | } 396 | 397 | return dateArray; 398 | } 399 | } 400 | }; 401 | }]); 402 | })(window.angular); 403 | -------------------------------------------------------------------------------- /dist/angular-pickadate.min.js: -------------------------------------------------------------------------------- 1 | !function(e){"use strict";function t(t,a){var n=[];return e.forEach(t,function(t){n.push(e.isFunction(a)?a(t):t[a])}),n}var a=[].indexOf||function(e){for(var t=0,a=this.length;t=300?l.left=r+a.getBoundingClientRect().left+"px":l.right=s-a.getBoundingClientRect().right-r+"px",l},o=function(e,t){for(var a=t.parentNode;null!==a;){if(a===e)return!0;a=a.parentNode}return!1};return function(e,n,i){var s=function(t){e.displayPicker=t,e.$apply()};n.on("focus",function(){e.modalStyles=r(n[0]),s(!0)}),n.on("keydown",function(e){a.call([9,13,27],e.keyCode)>=0&&s(!1)}),t.on("click",function(e){o(i,e.target)||e.target===n[0]||s(!1)})}}]).factory("pickadateDateHelper",["$locale","dateFilter",function(t,a){function n(e){switch(e){case"dd":return"day";case"MM":return"month";case"yyyy":return"year"}}return function(i,r){var o,s,l,c,d,u;return r=r||{},i=i||"yyyy-MM-dd",d=r.weekStartsOn,u=r.noExtraRows,l=r.disabledDates||e.noop,(!e.isNumber(d)||d<0||d>6)&&(d=0),{parseDate:function(t){if(t){if(e.isDate(t))return new Date(t);var a="(dd|MM|yyyy)",r=i.match(/[-|\/]/)[0],o=t.split(r),s=new RegExp([a,a,a].join(r)),l=i.match(s),c={};if(l.shift(),e.forEach(l,function(e,t){c[n(e)]=parseInt(o[t],10)}),!(isNaN(c.year)||isNaN(c.month)||isNaN(c.day)))return new Date(c.year,c.month-1,c.day,3)}},setRestrictions:function(e){o=this.parseDate(e.minDate)||new Date(0),s=this.parseDate(e.maxDate)||new Date(99999999999999),c=e.currentDate},allowPrevMonth:function(){return c>o},allowNextMonth:function(){var t=e.copy(c);return t.setMonth(t.getMonth()+1),t<=s},buildDateObject:function(t){var n=e.copy(t),d=a(n,i),u=l({date:n,formattedDate:d}),f=this.getMonthOffset(n,c),p=ns,D=f===-1&&!r.previousMonthSelectable||1===f&&!r.nextMonthSelectable;return{date:n,formattedDate:d,today:d===a(new Date,i),disabled:u,outOfMinRange:p,outOfMaxRange:h,monthOffset:f,enabled:!(u||p||h||D)}},buildDates:function(t,a,n){var i=[],r=new Date(t,a,1,3),o=new Date(t,a+1,0,3);for(n=n||{},c=e.copy(r);r.getDay()!==d;)r.setDate(r.getDate()-1);for(var s=0;s<42&&!(u&&r.getDay()===d&&r>o);s++)i.push(this.buildDateObject(r)),r.setDate(r.getDate()+1);return i},buildDayNames:function(){var e=t.DATETIME_FORMATS.SHORTDAY;if(d){e=e.slice(0);for(var a=0;a'+i.trustAsHtml(c.t("next"))+'

{{currentDate | date:"MMMM yyyy"}}

  • {{dayName}}
  • {{dateObj.date | date:"d"}}
';return{require:"ngModel",scope:{defaultDate:"=",minDate:"=",maxDate:"=",disabledDates:"&",weekStartsOn:"="},link:function(n,i,o,s){function c(){var e=k.buildDates(n.currentDate.getFullYear(),n.currentDate.getMonth());n.allowPrevMonth=k.allowPrevMonth(),n.allowNextMonth=k.allowNextMonth(),n.dayNames=k.buildDayNames(),n.dates=t(e,function(e){return e.classNames=[e.enabled?"pickadate-enabled":"pickadate-disabled"],e.today&&e.classNames.push("pickadate-today"),e.disabled&&e.classNames.push("pickadate-unavailable"),e})}function p(e,a){a=a||{},y?s.$setViewValue(t(e,"formattedDate")):s.$setViewValue(e[0]&&e[0].formattedDate),a.skipRenderInput||i.val(s.$viewValue)}function h(e,n){var i=a.call(t(n,"formattedDate"),e.formattedDate);return i===-1?n=D(e,n):(v||n.length>1)&&n.splice(i,1),n}function D(e,t){return y?t.push(e):t=[e],t}var y=o.hasOwnProperty("multiple"),v=o.hasOwnProperty("allowBlankDate"),g=[],m=i[0]instanceof HTMLInputElement,w=r(f)(n),M=(o.format||"yyyy-MM-dd").replace(/m/g,"M"),k=l(M,{previousMonthSelectable:/^(previous|both)$/.test(o.selectOtherMonths),nextMonthSelectable:/^(next|both)$/.test(o.selectOtherMonths),weekStartsOn:n.weekStartsOn,noExtraRows:o.hasOwnProperty("noExtraRows"),disabledDates:n.disabledDates});n.displayPicker=!m,n.setDate=function(e){e.enabled&&(g=h(e,g),p(g),n.changeMonth(e.monthOffset),n.displayPicker=!m)};var b=s.$render=function(a){e.isArray(s.$viewValue)?g=s.$viewValue:s.$viewValue&&(g=[s.$viewValue]),n.currentDate=k.parseDate(n.defaultDate||g[0])||new Date,k.setRestrictions(n),g=t(g,function(e){return k.buildDateObject(k.parseDate(e))}),g=u(g,{enabled:!0}),p(g,a),c()};n.classesFor=function(e){var n=t(g,"formattedDate"),i=a.call(n,e.formattedDate)>=0?"pickadate-active":null;return e.classNames.concat(i)},n.changeMonth=function(e){e&&(n.currentDate.setDate(1),n.currentDate.setMonth(n.currentDate.getMonth()+e),c())},n.$watch(function(){return e.toJson([n.minDate,n.maxDate])},b),m?(d(n,i,w[0]),n.$watch(function(){return s.$viewValue},function(e){var t=k.parseDate(e);t&&b({skipRenderInput:!0}),s.$setValidity("date",!!t)}),n.$$postDigest(function(){o.value&&(s.$viewValue=o.value,b())}),i.after(w.addClass("pickadate-modal"))):i.append(w)}}}])}(window.angular); -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | /* jshint strict: false, node: true */ 2 | 3 | var gulp = require('gulp'); 4 | var sass = require('gulp-sass'); 5 | var uglify = require('gulp-uglify'); 6 | var rename = require("gulp-rename"); 7 | var del = require("del"); 8 | var _ = require('lodash'); 9 | var karma = require('karma').server; 10 | var karmaConf = require('./karma.conf'); 11 | var jshint = require('gulp-jshint'); 12 | var legacyVersions = ['1.2.21', '1.3.6']; 13 | 14 | var karmaConfFor = function(version) { 15 | var conf = _.clone(karmaConf); 16 | conf.files = _.clone(karmaConf.files); 17 | conf.files.unshift('test/lib/angular-*' + version + '.js'); 18 | return conf; 19 | }; 20 | 21 | gulp.task('clean', function(done) { 22 | del('dist/*', done); 23 | }); 24 | 25 | gulp.task('dist', ['uglify', 'sass'], function() { 26 | return gulp.src('./src/*.js') 27 | .pipe(gulp.dest('./dist')); 28 | }); 29 | 30 | gulp.task('uglify', function() { 31 | gulp.src('./src/*.js') 32 | .pipe(uglify()) 33 | .pipe(rename({suffix: '.min'})) 34 | .pipe(gulp.dest('./dist')); 35 | }); 36 | 37 | gulp.task('sass', function () { 38 | gulp.src('./src/*.scss') 39 | .pipe(sass({ errLogToConsole: true })) 40 | .pipe(gulp.dest('./dist')); 41 | }); 42 | 43 | gulp.task('lint', function() { 44 | return gulp.src('./src/angular-pickadate.js') 45 | .pipe(jshint()) 46 | .pipe(jshint.reporter('default')) 47 | .pipe(jshint.reporter('fail')); 48 | }); 49 | 50 | legacyVersions.forEach(function(version) { 51 | gulp.task('test:legacy:' + version, function (done) { 52 | karma.start(_.assign({}, karmaConfFor(version), {singleRun: true}), done); 53 | }); 54 | 55 | gulp.task('tdd:legacy:' + version, function (done) { 56 | karma.start(karmaConfFor(version), done); 57 | }); 58 | }); 59 | 60 | gulp.task('test:legacy', legacyVersions.map(function(version) { 61 | return 'test:legacy:' + version; 62 | })); 63 | 64 | /** 65 | * Run test once and exit 66 | */ 67 | gulp.task('test', ['lint', 'test:legacy'], function (done) { 68 | karma.start(_.assign({}, karmaConfFor('1.4.0'), {singleRun: true}), done); 69 | }); 70 | 71 | 72 | gulp.task('tdd', function (done) { 73 | karma.start(karmaConfFor('1.4.0'), done); 74 | }); 75 | 76 | gulp.task('default', ['tdd']); 77 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | require('karma-chai-plugins'); 2 | 3 | // Karma configuration 4 | // Generated on Sat Aug 09 2014 18:30:57 GMT-0300 (ART) 5 | 6 | module.exports = { 7 | 8 | // base path that will be used to resolve all patterns (eg. files, exclude) 9 | basePath: '', 10 | 11 | 12 | // frameworks to use 13 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 14 | frameworks: ['mocha', 'chai', 'chai-jquery', 'sinon-chai'], 15 | 16 | 17 | // list of files / patterns to load in the browser 18 | files: [ 19 | 'test/lib/browser_trigger.js', 20 | 'src/angular-pickadate.js', 21 | 'node_modules/jquery/dist/jquery.js', 22 | 'test/**/*.spec.js' 23 | ], 24 | 25 | 26 | // list of files to exclude 27 | exclude: [ 28 | ], 29 | 30 | 31 | // preprocess matching files before serving them to the browser 32 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 33 | preprocessors: { 34 | }, 35 | 36 | 37 | // test results reporter to use 38 | // possible values: 'dots', 'progress' 39 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 40 | reporters: ['progress'], 41 | 42 | 43 | // web server port 44 | port: 9876, 45 | 46 | 47 | // enable / disable colors in the output (reporters and logs) 48 | colors: true, 49 | 50 | // start these browsers 51 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 52 | browsers: ['PhantomJS'], 53 | 54 | plugins: [ 55 | 'karma-mocha', 56 | require('karma-phantomjs-launcher'), 57 | require('karma-chai-plugins') 58 | ] 59 | }; 60 | 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-pickadate", 3 | "version": "2.0.2", 4 | "description": "A simple and fluid inline datepicker for AngularJS with no extra dependencies", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "gulp test" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/restorando/angular-pickadate.git" 12 | }, 13 | "keywords": [ 14 | "datepicker", 15 | "pickadate", 16 | "angular" 17 | ], 18 | "author": "Gabriel Schammah", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/restorando/angular-pickadate/issues" 22 | }, 23 | "homepage": "https://github.com/restorando/angular-pickadate", 24 | "devDependencies": { 25 | "del": "^2.2.0", 26 | "gulp": "^3.9.1", 27 | "gulp-jshint": "^2.0.0", 28 | "gulp-rename": "^1.2.2", 29 | "gulp-sass": "^2.3.2", 30 | "gulp-uglify": "^1.5.4", 31 | "jquery": "^2.2.3", 32 | "jshint": "^2.9.1", 33 | "karma": "^0.13.22", 34 | "karma-chai-plugins": "^0.7.0", 35 | "karma-mocha": "^0.2.2", 36 | "karma-phantomjs-launcher": "^1.0.0", 37 | "lodash": "^4.11.1", 38 | "mocha": "^2.4.5", 39 | "phantomjs-prebuilt": "^2.1.7" 40 | }, 41 | "dependencies": { 42 | "del": "^2.2.1" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/angular-pickadate.js: -------------------------------------------------------------------------------- 1 | ;(function(angular){ 2 | 'use strict'; 3 | var indexOf = [].indexOf || function(item) { 4 | for (var i = 0, l = this.length; i < l; i++) { 5 | if (i in this && this[i] === item) return i; 6 | } 7 | return -1; 8 | }; 9 | 10 | function map(items, property) { 11 | var mappedArray = []; 12 | angular.forEach(items, function(item) { 13 | mappedArray.push(angular.isFunction(property) ? property(item) : item[property]); 14 | }); 15 | return mappedArray; 16 | } 17 | 18 | angular.module('pickadate', []) 19 | 20 | .provider('pickadateI18n', function() { 21 | var defaults = { 22 | 'prev': 'prev', 23 | 'next': 'next' 24 | }; 25 | 26 | this.translations = {}; 27 | 28 | this.$get = function() { 29 | var translations = this.translations; 30 | 31 | return { 32 | t: function(key) { 33 | return translations[key] || defaults[key]; 34 | } 35 | }; 36 | }; 37 | }) 38 | 39 | .factory('pickadateModalBindings', ['$window', '$document', function($window, $document) { 40 | var supportPageOffset = $window.pageXOffset !== undefined, 41 | isCSS1Compat = (($document.compatMode || "") === "CSS1Compat"); 42 | 43 | var computeStyles = function(element) { 44 | var scrollX = supportPageOffset ? $window.pageXOffset : isCSS1Compat ? $document.documentElement.scrollLeft : $document.body.scrollLeft, 45 | scrollY = supportPageOffset ? $window.pageYOffset : isCSS1Compat ? $document.documentElement.scrollTop : $document.body.scrollTop, 46 | innerWidth = $window.innerWidth || $document.documentElement.clientWidth || $document.body.clientWidth, 47 | styles = { top: scrollY + element.getBoundingClientRect().bottom + 'px' }; 48 | 49 | if ((innerWidth - element.getBoundingClientRect().left ) >= 300) { 50 | styles.left = scrollX + element.getBoundingClientRect().left + 'px'; 51 | } else { 52 | styles.right = innerWidth - element.getBoundingClientRect().right - scrollX + 'px'; 53 | } 54 | 55 | return styles; 56 | }; 57 | 58 | var isDescendant = function(parent, child) { 59 | var node = child.parentNode; 60 | while (node !== null) { 61 | if (node === parent) return true; 62 | node = node.parentNode; 63 | } 64 | return false; 65 | }; 66 | 67 | return function(scope, element, rootNode) { 68 | var togglePicker = function(toggle) { 69 | scope.displayPicker = toggle; 70 | scope.$apply(); 71 | }; 72 | 73 | element.on('focus', function() { 74 | scope.modalStyles = computeStyles(element[0]); 75 | togglePicker(true); 76 | }); 77 | 78 | element.on('keydown', function(e) { 79 | if (indexOf.call([9, 13, 27], e.keyCode) >= 0) togglePicker(false); 80 | }); 81 | 82 | $document.on('click', function(e) { 83 | if (isDescendant(rootNode, e.target) || e.target === element[0]) return; 84 | togglePicker(false); 85 | }); 86 | }; 87 | 88 | }]) 89 | 90 | .factory('pickadateDateHelper', ['$locale', 'dateFilter', function($locale, dateFilter) { 91 | 92 | function getPartName(part) { 93 | switch (part) { 94 | case 'dd': return 'day'; 95 | case 'MM': return 'month'; 96 | case 'yyyy': return 'year'; 97 | } 98 | } 99 | 100 | return function(format, options) { 101 | var minDate, maxDate, disabledDates, currentDate, weekStartsOn, noExtraRows; 102 | 103 | options = options || {}; 104 | format = format || 'yyyy-MM-dd'; 105 | weekStartsOn = options.weekStartsOn; 106 | noExtraRows = options.noExtraRows; 107 | disabledDates = options.disabledDates || angular.noop; 108 | 109 | if (!angular.isNumber(weekStartsOn) || weekStartsOn < 0 || weekStartsOn > 6) weekStartsOn = 0; 110 | 111 | return { 112 | 113 | parseDate: function(dateString) { 114 | if (!dateString) return; 115 | if (angular.isDate(dateString)) return new Date(dateString); 116 | 117 | var formatRegex = '(dd|MM|yyyy)', 118 | separator = format.match(/[-|/]/)[0], 119 | dateParts = dateString.split(separator), 120 | regexp = new RegExp([formatRegex, formatRegex, formatRegex].join(separator)), 121 | formatParts = format.match(regexp), 122 | dateObj = {}; 123 | 124 | formatParts.shift(); 125 | 126 | angular.forEach(formatParts, function(part, i) { 127 | dateObj[getPartName(part)] = parseInt(dateParts[i], 10); 128 | }); 129 | 130 | if (isNaN(dateObj.year) || isNaN(dateObj.month) || isNaN(dateObj.day)) return; 131 | 132 | return new Date(dateObj.year, dateObj.month - 1, dateObj.day, 3); 133 | }, 134 | 135 | setRestrictions: function(restrictions) { 136 | minDate = this.parseDate(restrictions.minDate) || new Date(0); 137 | maxDate = this.parseDate(restrictions.maxDate) || new Date(99999999999999); 138 | currentDate = restrictions.currentDate; 139 | }, 140 | 141 | allowPrevMonth: function() { 142 | return currentDate > minDate; 143 | }, 144 | 145 | allowNextMonth: function() { 146 | var nextMonth = angular.copy(currentDate); 147 | nextMonth.setMonth(nextMonth.getMonth() + 1); 148 | return nextMonth <= maxDate; 149 | }, 150 | 151 | buildDateObject: function(date) { 152 | var localDate = angular.copy(date), 153 | formattedDate = dateFilter(localDate, format), 154 | disabled = disabledDates({date: localDate, formattedDate: formattedDate}), 155 | monthOffset = this.getMonthOffset(localDate, currentDate), 156 | outOfMinRange = localDate < minDate, 157 | outOfMaxRange = localDate > maxDate, 158 | outOfMonth = (monthOffset === -1 && !options.previousMonthSelectable) || 159 | (monthOffset === 1 && !options.nextMonthSelectable); 160 | 161 | return { 162 | date: localDate, 163 | formattedDate: formattedDate, 164 | today: formattedDate === dateFilter(new Date(), format), 165 | disabled: disabled, 166 | outOfMinRange: outOfMinRange, 167 | outOfMaxRange: outOfMaxRange, 168 | monthOffset: monthOffset, 169 | enabled: !(disabled || outOfMinRange || outOfMaxRange || outOfMonth) 170 | }; 171 | }, 172 | 173 | buildDates: function(year, month, options) { 174 | var dates = [], 175 | date = new Date(year, month, 1, 3), 176 | lastDate = new Date(year, month + 1, 0, 3); 177 | 178 | options = options || {}; 179 | currentDate = angular.copy(date); 180 | 181 | while (date.getDay() !== weekStartsOn) date.setDate(date.getDate() - 1); 182 | 183 | for (var i = 0; i < 42; i++) { // 42 == 6 rows of dates 184 | if (noExtraRows && date.getDay() === weekStartsOn && date > lastDate) break; 185 | 186 | dates.push(this.buildDateObject(date)); 187 | date.setDate(date.getDate() + 1); 188 | } 189 | 190 | return dates; 191 | }, 192 | 193 | buildDayNames: function() { 194 | var dayNames = $locale.DATETIME_FORMATS.SHORTDAY; 195 | 196 | if (weekStartsOn) { 197 | dayNames = dayNames.slice(0); 198 | for (var i = 0; i < weekStartsOn; i++) dayNames.push(dayNames.shift()); 199 | } 200 | return dayNames; 201 | }, 202 | 203 | getMonthOffset: function(date1, date2) { 204 | return date1.getMonth() - date2.getMonth() + (12 * (date1.getFullYear() - date2.getFullYear())); 205 | } 206 | }; 207 | }; 208 | 209 | }]) 210 | 211 | .directive('pickadate', ['$locale', '$sce', '$compile', '$document', '$window', 'pickadateDateHelper', 212 | 'pickadateI18n', 'pickadateModalBindings', 'filterFilter', function($locale, $sce, $compile, $document, $window, 213 | dateHelperFactory, i18n, modalBindings, filter) { 214 | 215 | var TEMPLATE = 216 | '
' + 217 | '
' + 218 | ''+ 226 | '

' + 227 | '{{currentDate | date:"MMMM yyyy"}}' + 228 | '

' + 229 | '
' + 230 | '
' + 231 | '
' + 232 | '
    ' + 233 | '
  • ' + 234 | '{{dayName}}' + 235 | '
  • ' + 236 | '
' + 237 | '
    ' + 238 | '
  • ' + 239 | '{{dateObj.date | date:"d"}}' + 240 | '
  • ' + 241 | '
' + 242 | '
' + 243 | '
' + 244 | '
'; 245 | 246 | return { 247 | require: 'ngModel', 248 | scope: { 249 | defaultDate: '=', 250 | minDate: '=', 251 | maxDate: '=', 252 | disabledDates: '&', 253 | weekStartsOn: '=' 254 | }, 255 | 256 | link: function(scope, element, attrs, ngModel) { 257 | var allowMultiple = attrs.hasOwnProperty('multiple'), 258 | allowBlank = attrs.hasOwnProperty('allowBlankDate'), 259 | selectedDates = [], 260 | wantsModal = element[0] instanceof HTMLInputElement, 261 | compiledHtml = $compile(TEMPLATE)(scope), 262 | format = (attrs.format || 'yyyy-MM-dd').replace(/m/g, 'M'), 263 | dateHelper = dateHelperFactory(format, { 264 | previousMonthSelectable: /^(previous|both)$/.test(attrs.selectOtherMonths), 265 | nextMonthSelectable: /^(next|both)$/.test(attrs.selectOtherMonths), 266 | weekStartsOn: scope.weekStartsOn, 267 | noExtraRows: attrs.hasOwnProperty('noExtraRows'), 268 | disabledDates: scope.disabledDates 269 | }); 270 | 271 | scope.displayPicker = !wantsModal; 272 | 273 | scope.setDate = function(dateObj) { 274 | if (!dateObj.enabled) return; 275 | 276 | selectedDates = toggleDate(dateObj, selectedDates); 277 | 278 | setViewValue(selectedDates); 279 | 280 | scope.changeMonth(dateObj.monthOffset); 281 | scope.displayPicker = !wantsModal; 282 | }; 283 | 284 | var $render = ngModel.$render = function(options) { 285 | if (angular.isArray(ngModel.$viewValue)) { 286 | selectedDates = ngModel.$viewValue; 287 | } else if (ngModel.$viewValue) { 288 | selectedDates = [ngModel.$viewValue]; 289 | } 290 | 291 | scope.currentDate = dateHelper.parseDate(scope.defaultDate || selectedDates[0]) || new Date(); 292 | 293 | dateHelper.setRestrictions(scope); 294 | 295 | selectedDates = map(selectedDates, function(date) { 296 | return dateHelper.buildDateObject(dateHelper.parseDate(date)); 297 | }); 298 | 299 | selectedDates = filter(selectedDates, { enabled: true }); 300 | 301 | setViewValue(selectedDates, options); 302 | render(); 303 | }; 304 | 305 | scope.classesFor = function(date) { 306 | var formattedDates = map(selectedDates, 'formattedDate'), 307 | classes = indexOf.call(formattedDates, date.formattedDate) >= 0 ? 'pickadate-active' : null; 308 | return date.classNames.concat(classes); 309 | }; 310 | 311 | scope.changeMonth = function(offset) { 312 | if (!offset) return; 313 | // If the current date is January 31th, setting the month to date.getMonth() + 1 314 | // sets the date to March the 3rd, since the date object adds 30 days to the current 315 | // date. Settings the date to the 2nd day of the month is a workaround to prevent this 316 | // behaviour 317 | scope.currentDate.setDate(1); 318 | scope.currentDate.setMonth(scope.currentDate.getMonth() + offset); 319 | render(); 320 | }; 321 | 322 | // Workaround to watch multiple properties. XXX use $scope.$watchGroup in angular 1.3 323 | scope.$watch(function() { 324 | return angular.toJson([scope.minDate, scope.maxDate]); 325 | }, $render); 326 | 327 | // Insert datepicker into DOM 328 | if (wantsModal) { 329 | modalBindings(scope, element, compiledHtml[0]); 330 | 331 | // if the user types a date, update the picker and set validity 332 | scope.$watch(function() { 333 | return ngModel.$viewValue; 334 | }, function(val) { 335 | var isValidDate = dateHelper.parseDate(val); 336 | 337 | if (isValidDate) $render({ skipRenderInput: true }); 338 | ngModel.$setValidity('date', !!isValidDate); 339 | }); 340 | 341 | // if the input element has a value, set it as the ng-model 342 | scope.$$postDigest(function() { 343 | if (attrs.value) { ngModel.$viewValue = attrs.value; $render(); } 344 | }); 345 | 346 | element.after(compiledHtml.addClass('pickadate-modal')); 347 | } else { 348 | element.append(compiledHtml); 349 | } 350 | 351 | function render() { 352 | var dates = dateHelper.buildDates(scope.currentDate.getFullYear(), scope.currentDate.getMonth()); 353 | 354 | scope.allowPrevMonth = dateHelper.allowPrevMonth(); 355 | scope.allowNextMonth = dateHelper.allowNextMonth(); 356 | scope.dayNames = dateHelper.buildDayNames(); 357 | 358 | scope.dates = map(dates, function(date) { 359 | date.classNames = [date.enabled ? 'pickadate-enabled' : 'pickadate-disabled']; 360 | 361 | if (date.today) date.classNames.push('pickadate-today'); 362 | if (date.disabled) date.classNames.push('pickadate-unavailable'); 363 | 364 | return date; 365 | }); 366 | } 367 | 368 | function setViewValue(value, options) { 369 | options = options || {}; 370 | 371 | if (allowMultiple) { 372 | ngModel.$setViewValue(map(value, 'formattedDate')); 373 | } else { 374 | ngModel.$setViewValue(value[0] && value[0].formattedDate); 375 | } 376 | 377 | if (!options.skipRenderInput) element.val(ngModel.$viewValue); 378 | } 379 | 380 | function toggleDate(dateObj, dateArray) { 381 | var index = indexOf.call(map(dateArray, 'formattedDate'), dateObj.formattedDate); 382 | if (index === -1) { 383 | dateArray = addDate(dateObj, dateArray); 384 | } else if (allowBlank || (dateArray.length > 1)) { 385 | dateArray.splice(index, 1); 386 | } 387 | return dateArray; 388 | } 389 | 390 | function addDate(dateObj, dateArray){ 391 | if (allowMultiple) { 392 | dateArray.push(dateObj); 393 | } else { 394 | dateArray = [dateObj]; 395 | } 396 | 397 | return dateArray; 398 | } 399 | } 400 | }; 401 | }]); 402 | })(window.angular); 403 | -------------------------------------------------------------------------------- /src/angular-pickadate.scss: -------------------------------------------------------------------------------- 1 | .pickadate { 2 | font-family: 'Helvetica Neue', Helvetica, Helvetica, Arial, sans-serif; 3 | 4 | a { 5 | &:visited { 6 | color: #666666; 7 | } 8 | color: #666666; 9 | } 10 | } 11 | 12 | .pickadate-header { 13 | position: relative; 14 | } 15 | 16 | .pickadate-main { 17 | margin: 0; 18 | padding: 0; 19 | width: 100%; 20 | text-align: center; 21 | font-size: 12px; 22 | } 23 | 24 | .pickadate-cell { 25 | overflow: hidden; 26 | margin: 0; 27 | padding: 0; 28 | li { 29 | display: block; 30 | float: left; 31 | border: 1px solid #DCDCDC; 32 | border-width: 0 1px 1px 0; 33 | width: 14.285%; 34 | padding: 1.3% 0 1.3% 0; 35 | line-height: normal; 36 | box-sizing: border-box; 37 | -moz-box-sizing: border-box; 38 | -webkit-box-sizing: border-box; 39 | &:nth-child(7n+0) { 40 | border-right: 1px solid #DCDCDC; 41 | } 42 | &:nth-child(1), &:nth-child(8), &:nth-child(15), &:nth-child(22), &:nth-child(29), &:nth-child(36) { 43 | border-left: 1px solid #DCDCDC; 44 | } 45 | } 46 | .pickadate-disabled { 47 | color: #DCDCDC; 48 | a { 49 | color: #DCDCDC; 50 | } 51 | } 52 | .pickadate-enabled { 53 | cursor: pointer; 54 | font-size: 12px; 55 | color: #666666; 56 | } 57 | .pickadate-today { 58 | background-color: #eaeaea; 59 | } 60 | .pickadate-active { 61 | background-color: #b52a00; 62 | color: white; 63 | } 64 | .pickadate-head { 65 | border-top: 1px solid #DCDCDC; 66 | background: #f3f3f3; 67 | &:nth-child(1), &:nth-child(7) { 68 | background: #f3f3f3; 69 | } 70 | } 71 | } 72 | 73 | .pickadate-centered-heading { 74 | font-weight: normal; 75 | text-align: center; 76 | font-size: 1em; 77 | margin: 13px 0 13px 0; 78 | line-height: normal; 79 | } 80 | 81 | .pickadate-controls { 82 | position: absolute; 83 | z-index: 10; 84 | width: 100%; 85 | .pickadate-next { 86 | float: right; 87 | } 88 | a { 89 | text-decoration: none; 90 | font-size: 0.9em; 91 | } 92 | } 93 | 94 | .pickadate-modal { 95 | position: absolute; 96 | background-color: #fff; 97 | width: 300px; 98 | border: 1px solid #ccc; 99 | border-radius: 4px; 100 | padding: 0 5px 5px 5px; 101 | z-index: 1000; 102 | } 103 | -------------------------------------------------------------------------------- /test/angular-pickadate.spec.js: -------------------------------------------------------------------------------- 1 | /* jshint expr: true */ 2 | 3 | describe('pickadate', function () { 4 | 'use strict'; 5 | 6 | var element, 7 | $scope, 8 | $compile, 9 | $document, 10 | pickadateI18nProvider, 11 | defaultHtml = '
' + 15 | '
'; 16 | 17 | beforeEach(module('pickadate')); 18 | 19 | beforeEach(module(['pickadateI18nProvider', function(_pickadateI18nProvider_) { 20 | pickadateI18nProvider = _pickadateI18nProvider_; 21 | }])); 22 | 23 | beforeEach(function() { 24 | inject(function($rootScope, _$compile_, _$document_){ 25 | $scope = $rootScope.$new(); 26 | $compile = _$compile_; 27 | $document = _$document_; 28 | $scope.disabledDatesFn = function(date, formattedDate) { 29 | return ($scope.disabledDates || []).indexOf(formattedDate) > -1; 30 | } 31 | }); 32 | }); 33 | 34 | function compile(html) { 35 | element = angular.element(html || defaultHtml); 36 | $compile(element)($scope); 37 | $scope.$digest(); 38 | } 39 | 40 | function $(selector) { 41 | return jQuery(selector, element); 42 | } 43 | 44 | describe('Model binding', function() { 45 | 46 | beforeEach(function() { 47 | $scope.date = '2014-05-17'; 48 | $scope.disabledDates = ['2014-05-26']; 49 | compile(); 50 | }); 51 | 52 | it("updates the ngModel value when a date is clicked", function() { 53 | expect($scope.date).to.equal('2014-05-17'); 54 | browserTrigger($('.pickadate-enabled:contains(27)'), 'click'); 55 | expect($scope.date).to.equal('2014-05-27'); 56 | }); 57 | 58 | it("doesn't allow an unavailable date to be clicked", function() { 59 | expect($scope.date).to.equal('2014-05-17'); 60 | browserTrigger($('.pickadate-enabled:contains(26)'), 'click'); 61 | expect($scope.date).to.equal('2014-05-17'); 62 | }); 63 | 64 | it("sets the ngModel as undefined if the model date is in the disabled list", function() { 65 | $scope.date = '2014-05-26'; 66 | $scope.$digest(); 67 | expect($scope.date).to.be.undefined; 68 | }); 69 | 70 | }); 71 | 72 | describe('Rendering', function() { 73 | 74 | beforeEach(function() { 75 | $scope.date = '2014-05-17'; 76 | $scope.disabledDates = ['2014-05-26']; 77 | compile(); 78 | }); 79 | 80 | describe('Selected date', function() { 81 | 82 | it("doesn't add the pickadate-modal class", function() { 83 | expect($('.pickadate')).not.to.have.class('pickadate-modal'); 84 | }); 85 | 86 | it("adds the 'pickadate-active' class for the selected date", function() { 87 | expect($('.pickadate-active')).to.have.text('17'); 88 | expect($('.pickadate-active').length).to.equal(1); 89 | 90 | browserTrigger($('.pickadate-enabled:contains(27)'), 'click'); 91 | 92 | expect($('.pickadate-active')).to.have.text('27'); 93 | expect($('.pickadate-active').length).to.equal(1); 94 | }); 95 | 96 | it("doesn't have an element with the 'pickadate-active' class for the next month", function() { 97 | browserTrigger($('.pickadate-next'), 'click'); 98 | expect($('.pickadate-active').length).to.be.empty; 99 | }); 100 | 101 | it("doesn't change the active element when a disabled date is clicked", function() { 102 | browserTrigger($('.pickadate-enabled:contains(26)'), 'click'); 103 | expect($('.pickadate-active')).to.have.text('17'); 104 | expect($('.pickadate-active').length).to.equal(1); 105 | }); 106 | 107 | }); 108 | 109 | describe('Disabled dates', function() { 110 | 111 | describe('As an array', function() { 112 | beforeEach(function() { 113 | $scope.disabledDates = ['2014-05-20', '2014-05-26']; 114 | compile(); 115 | }); 116 | 117 | it("adds the 'pickadate-unavailable' class to the disabled dates", function() { 118 | expect($('li:contains(20)')).to.have.class('pickadate-unavailable'); 119 | expect($('li:contains(26)')).to.have.class('pickadate-unavailable'); 120 | }); 121 | 122 | it("doesn't change the selected date if an unavailable one is clicked", function() { 123 | browserTrigger($('.pickadate-unavailable:contains(20)'), 'click'); 124 | expect($scope.date).to.equal('2014-05-17'); 125 | }); 126 | }); 127 | 128 | describe('As a function', function() { 129 | beforeEach(function() { 130 | $scope.disabledDatesFn = function(date) { 131 | return date.getDay() <= 1; 132 | } 133 | compile(); 134 | }); 135 | 136 | it("adds the 'pickadate-unavailable' class to the disabled dates", function() { 137 | expect($('li:contains(11)')).to.have.class('pickadate-unavailable'); 138 | expect($('li:contains(12)')).to.have.class('pickadate-unavailable'); 139 | expect($('li:contains(18)')).to.have.class('pickadate-unavailable'); 140 | expect($('li:contains(19)')).to.have.class('pickadate-unavailable'); 141 | expect($('li:contains(25)')).to.have.class('pickadate-unavailable'); 142 | expect($('li:contains(26)')).to.have.class('pickadate-unavailable'); 143 | }); 144 | }) 145 | 146 | }); 147 | 148 | describe('Watchers', function() { 149 | 150 | describe('Min && max date', function() { 151 | 152 | beforeEach(function() { 153 | $scope.minDate = '2014-04-20'; 154 | $scope.maxDate = '2014-06-20'; 155 | $scope.date = '2014-05-17'; 156 | compile(); 157 | }); 158 | 159 | it("re-renders the calendar if min-date is updated", function() { 160 | expect($('li:contains(14)')).not.to.have.class('pickadate-disabled'); 161 | 162 | $scope.minDate = '2014-05-15'; 163 | $scope.$digest(); 164 | 165 | expect($('li:contains(14)')).to.have.class('pickadate-disabled'); 166 | expect($('li:contains(15)')).not.to.have.class('pickadate-disabled'); 167 | }); 168 | 169 | it("re-renders the calendar if max-date is updated", function() { 170 | expect($('li:contains(23)')).not.to.have.class('pickadate-disabled'); 171 | 172 | $scope.maxDate = '2014-05-22'; 173 | $scope.$digest(); 174 | 175 | expect($('li:contains(22)')).not.to.have.class('pickadate-disabled'); 176 | expect($('li:contains(23)')).to.have.class('pickadate-disabled'); 177 | }); 178 | 179 | it("unselects the current date if it's not in the min-date - max-date range", function() { 180 | $scope.minDate = '2014-05-19'; 181 | $scope.$digest(); 182 | 183 | expect($scope.date).to.be.undefined; 184 | }); 185 | 186 | it("re-renders the calendar on the selected date", function() { 187 | $scope.date = '2014-06-20'; 188 | $scope.$digest(); 189 | 190 | expect($('.pickadate-centered-heading')).to.have.text('June 2014'); 191 | expect($('li:contains(20)')).to.have.class('pickadate-active'); 192 | }); 193 | 194 | }); 195 | 196 | }); 197 | 198 | }); 199 | 200 | describe('Month boundaries restrictions', function() { 201 | 202 | beforeEach(function() { 203 | $scope.date = '2014-05-17'; 204 | compile(); 205 | }); 206 | 207 | describe("when selectOtherMonths attribute has the 'next' value", function() { 208 | 209 | it("adds 'pickadate-enabled' class to next month dates", function() { 210 | expect($('li').last()).to.have.class('pickadate-enabled'); 211 | }); 212 | 213 | it("doesn't add 'pickadate-enabled' class to previous month dates", function() { 214 | expect($('li:contains(29)').first()).not.to.have.class('pickadate-enabled'); 215 | }); 216 | 217 | it("changes to the next month if a date from that other month is clicked", function() { 218 | browserTrigger($('li').last(), 'click'); 219 | expect($('.pickadate-centered-heading')).to.have.text('June 2014'); 220 | }); 221 | 222 | describe("when the month is December", function() { 223 | 224 | beforeEach(function() { 225 | $scope.date = '2014-12-22'; 226 | compile(); 227 | }); 228 | 229 | it("adds 'pickadate-enabled' class to next month/year dates", function() { 230 | expect($('li').last()).to.have.class('pickadate-enabled'); 231 | }); 232 | 233 | it("changes to January when a date from that month is clicked", function() { 234 | browserTrigger($('li').last(), 'click'); 235 | expect($('.pickadate-centered-heading')).to.have.text('January 2015'); 236 | }); 237 | }); 238 | }); 239 | 240 | describe("when selectOtherMonths attribute has the 'previous' value", function() { 241 | 242 | var html = '
'; 243 | 244 | beforeEach(function() { 245 | compile(html); 246 | }); 247 | 248 | it("doesn't add 'pickadate-enabled' class to next month dates", function() { 249 | expect($('li').last()).not.to.have.class('pickadate-enabled'); 250 | }); 251 | 252 | it("adds 'pickadate-enabled' class to previous month dates", function() { 253 | expect($('li:contains(29)').first()).to.have.class('pickadate-enabled'); 254 | }); 255 | 256 | it("changes to the previous month if a date from that other month is clicked", function() { 257 | browserTrigger($('li:contains(29)').first(), 'click'); 258 | expect($('.pickadate-centered-heading')).to.have.text('April 2014'); 259 | }); 260 | 261 | describe("when the month is January", function() { 262 | 263 | beforeEach(function() { 264 | $scope.date = '2015-01-22'; 265 | compile(html); 266 | }); 267 | 268 | it("adds 'pickadate-enabled' class to previous month/year dates", function() { 269 | expect($('li:contains(29)').first()).to.have.class('pickadate-enabled'); 270 | }); 271 | 272 | it("changes to December when a date from that month is clicked", function() { 273 | browserTrigger($('li:contains(29)').first(), 'click'); 274 | expect($('.pickadate-centered-heading')).to.have.text('December 2014'); 275 | }); 276 | }); 277 | }); 278 | 279 | describe("when selectOtherMonths attribute has the 'both' value", function() { 280 | 281 | var html = '
'; 282 | 283 | beforeEach(function() { 284 | compile(html); 285 | }); 286 | 287 | it("adds 'pickadate-enabled' class to next month dates", function() { 288 | expect($('li').last()).to.have.class('pickadate-enabled'); 289 | }); 290 | 291 | it("adds 'pickadate-enabled' class to previous month dates", function() { 292 | expect($('li:contains(29)').first()).to.have.class('pickadate-enabled'); 293 | }); 294 | 295 | it("changes to the next month if a date from that other month is clicked", function() { 296 | browserTrigger($('li').last(), 'click'); 297 | expect($('.pickadate-centered-heading')).to.have.text('June 2014'); 298 | }); 299 | 300 | it("changes to the previous month if a date from that other month is clicked", function() { 301 | browserTrigger($('li:contains(29)').first(), 'click'); 302 | expect($('.pickadate-centered-heading')).to.have.text('April 2014'); 303 | }); 304 | }); 305 | 306 | describe("when selectOtherMonths attribute has no value", function() { 307 | 308 | var html = '
'; 309 | 310 | it("both previous and next month dates are disabled", function() { 311 | compile(html); 312 | expect($('li').last()).not.to.have.class('pickadate-enabled'); 313 | expect($('li:contains(29)').first()).not.to.have.class('pickadate-enabled'); 314 | }); 315 | }); 316 | }); 317 | 318 | describe('Month Navigation', function() { 319 | 320 | beforeEach(function() { 321 | $scope.date = '2014-05-17'; 322 | }); 323 | 324 | it("renders the current month", function(){ 325 | compile(); 326 | expect($('.pickadate-centered-heading')).to.have.text('May 2014'); 327 | }); 328 | 329 | it("changes to the previous month", function() { 330 | compile(); 331 | 332 | browserTrigger($('.pickadate-prev'), 'click'); 333 | expect($('.pickadate-centered-heading')).to.have.text('April 2014'); 334 | 335 | browserTrigger($('.pickadate-prev'), 'click'); 336 | expect($('.pickadate-centered-heading')).to.have.text('March 2014'); 337 | }); 338 | 339 | it("changes to the next month", function() { 340 | compile(); 341 | 342 | browserTrigger($('.pickadate-next'), 'click'); 343 | expect($('.pickadate-centered-heading')).to.have.text('June 2014'); 344 | 345 | browserTrigger($('.pickadate-next'), 'click'); 346 | expect($('.pickadate-centered-heading')).to.have.text('July 2014'); 347 | }); 348 | 349 | describe('Disabled months', function() { 350 | 351 | it("doesn't render the prev button if prev month < minDate", function() { 352 | $scope.minDate = '2014-04-04'; 353 | compile(); 354 | 355 | expect($('.pickadate-prev')).not.to.have.class('ng-hide'); 356 | 357 | browserTrigger($('.pickadate-prev'), 'click'); 358 | expect($('.pickadate-centered-heading')).to.have.text('April 2014'); 359 | 360 | expect($('.pickadate-prev')).to.have.class('ng-hide'); 361 | }); 362 | 363 | it("doesn't render the next button if next month > maxDate", function() { 364 | $scope.maxDate = '2014-06-04'; 365 | compile(); 366 | 367 | expect($('.pickadate-next')).not.to.have.class('ng-hide'); 368 | 369 | browserTrigger($('.pickadate-next'), 'click'); 370 | expect($('.pickadate-centered-heading')).to.have.text('June 2014'); 371 | 372 | expect($('.pickadate-next')).to.have.class('ng-hide'); 373 | }); 374 | 375 | it("renders the next button if maxDate is set to the beginning of a month", function() { 376 | $scope.date = '2014-08-31'; 377 | $scope.maxDate = '2014-09-01'; 378 | compile(); 379 | 380 | expect($('.pickadate-next')).not.to.have.class('ng-hide'); 381 | }); 382 | 383 | }); 384 | }); 385 | 386 | describe('Configure the first day of the week', function() { 387 | var defaultDay; 388 | 389 | var firstCalendarDay = function(weekStartsOn) { 390 | $scope.weekStartsOn = weekStartsOn; 391 | compile(); 392 | return $('ul:last-child li:first-child').text(); 393 | }; 394 | 395 | beforeEach(function() { 396 | defaultDay = firstCalendarDay(0); 397 | }); 398 | 399 | it('changes the first day of the week', function() { 400 | for (var weekStartsOn = 1; weekStartsOn < 7; weekStartsOn++) { 401 | expect(firstCalendarDay(weekStartsOn)).to.not.equal(defaultDay); 402 | } 403 | }); 404 | 405 | it('sets weekStartsOn to 0 if it is invalid', function() { 406 | angular.forEach([7, -1, 'foo'], function(weekStartsOn) { 407 | expect(firstCalendarDay(weekStartsOn)).to.equal(defaultDay); 408 | }); 409 | }); 410 | 411 | }); 412 | 413 | describe('Default date', function() { 414 | beforeEach(function() { 415 | this.clock = sinon.useFakeTimers(1431025777408); 416 | }); 417 | 418 | afterEach(function() { 419 | this.clock.restore(); 420 | }); 421 | 422 | it("renders the specified yearMonth by default if no date is selected", function() { 423 | $scope.defaultDate = '2014-11-10'; 424 | compile(); 425 | 426 | expect($('.pickadate-centered-heading')).to.have.text('November 2014'); 427 | }); 428 | 429 | it("renders the specified yearMonth by default even if a date is selected", function() { 430 | $scope.defaultDate = '2014-11-10'; 431 | $scope.date = '2014-03-01'; 432 | compile(); 433 | 434 | expect($('.pickadate-centered-heading')).to.have.text('November 2014'); 435 | }); 436 | 437 | it("renders the current month if no date is selected and no default date is specified ", function() { 438 | compile(); 439 | 440 | expect($('.pickadate-centered-heading')).to.have.text('May 2015'); 441 | }); 442 | 443 | it("renders the selected date month if no default date is specified", function() { 444 | $scope.date = '2014-08-03'; 445 | compile(); 446 | 447 | expect($('.pickadate-centered-heading')).to.have.text('August 2014'); 448 | }); 449 | 450 | }); 451 | 452 | describe('Translations', function() { 453 | 454 | it('uses the default translations if not translations are specified', function() { 455 | compile(); 456 | expect($('.pickadate-prev')).to.have.text('prev'); 457 | expect($('.pickadate-next')).to.have.text('next'); 458 | }); 459 | 460 | it('uses the translations previously set in the pickadateI18nProvider', function() { 461 | pickadateI18nProvider.translations = { 462 | prev: 'ant', 463 | next: 'sig' 464 | }; 465 | compile(); 466 | expect($('.pickadate-prev')).to.have.text('ant'); 467 | expect($('.pickadate-next')).to.have.text('sig'); 468 | }); 469 | 470 | it('accepts valid html translations', function() { 471 | pickadateI18nProvider.translations = { 472 | prev: ' ant', 473 | next: 'sig ' 474 | }; 475 | compile(); 476 | expect($('.pickadate-prev')).to.have.html(' ant'); 477 | expect($('.pickadate-next')).to.have.html('sig '); 478 | }); 479 | 480 | }); 481 | 482 | describe('Using only required scope properties', function() { 483 | 484 | it("doesn't throw an error if only the required scope properties are being binded", function() { 485 | expect(function(){ 486 | compile('
'); 487 | }).not.to.throw(); 488 | 489 | }); 490 | 491 | }); 492 | 493 | describe('Date formats', function() { 494 | 495 | beforeEach(function() { 496 | this.clock = sinon.useFakeTimers(1431025777408); 497 | }); 498 | 499 | afterEach(function() { 500 | this.clock.restore(); 501 | }); 502 | 503 | var compileFormat = function(format) { 504 | compile('
'); 505 | }; 506 | 507 | it("sets the date in the right format after selecting it", function() { 508 | compileFormat('dd/MM/yyyy'); 509 | browserTrigger($('.pickadate-enabled:first'), 'click'); 510 | expect($scope.date).to.equal('01/05/2015'); 511 | }); 512 | 513 | it("takes the initial date in the right format", function() { 514 | $scope.date = '20/02/2012'; 515 | compileFormat('dd/mm/yyyy'); 516 | 517 | expect($scope.date).to.equal('20/02/2012'); 518 | expect($('.pickadate-centered-heading')).to.have.text('February 2012'); 519 | expect($('li:contains(20)')).to.have.class('pickadate-active'); 520 | }); 521 | 522 | }); 523 | 524 | describe('Multiple dates', function() { 525 | 526 | beforeEach(function() { 527 | this.clock = sinon.useFakeTimers(1431025777408); 528 | }); 529 | 530 | afterEach(function() { 531 | this.clock.restore(); 532 | }); 533 | 534 | describe('When ng-model is initially empty', function() { 535 | 536 | beforeEach(function() { 537 | compile('
'); 538 | }); 539 | 540 | it('sets the current ngModel to an empty array if its undefined', function() { 541 | expect($scope.date).to.be.empty; 542 | }); 543 | 544 | it('adds the selected date to the ngModel array', function() { 545 | browserTrigger($('.pickadate-enabled:contains(20)'), 'click'); 546 | browserTrigger($('.pickadate-enabled:contains(22)'), 'click'); 547 | expect($scope.date).to.deep.equal(['2015-05-20', '2015-05-22']); 548 | }); 549 | 550 | it('removes the selected date of the ngModel array if it was previously selected', function() { 551 | browserTrigger($('.pickadate-enabled:contains(7)'), 'click'); 552 | browserTrigger($('.pickadate-enabled:contains(15)'), 'click'); 553 | expect($scope.date).to.deep.equal(['2015-05-07', '2015-05-15']); 554 | 555 | browserTrigger($('.pickadate-enabled:contains(7)'), 'click'); 556 | expect($scope.date).to.deep.equal(['2015-05-15']); 557 | }); 558 | }) 559 | 560 | describe('When an ng-model was already set', function() { 561 | 562 | beforeEach(function() { 563 | $scope.date = ['2015-05-07', '2015-05-10', '2015-05-13']; 564 | compile('
'); 565 | }) 566 | 567 | it('removes the selected date of the ngModel array if it was previously selected', function() { 568 | browserTrigger($('.pickadate-enabled:contains(7)'), 'click'); 569 | expect($scope.date).to.deep.equal(['2015-05-10', '2015-05-13']) 570 | }); 571 | 572 | }) 573 | 574 | describe('Rendering', function() { 575 | 576 | beforeEach(function() { 577 | compile('
'); 578 | }); 579 | 580 | it('renders the multiple dates', function() { 581 | $scope.date = ['2015-05-07', '2015-05-10', '2015-05-13']; 582 | $scope.$digest(); 583 | 584 | expect($('.pickadate-active').get()).to.have.length(3); 585 | expect($('.pickadate-active:eq(0)')).to.have.text('7'); 586 | expect($('.pickadate-active:eq(1)')).to.have.text('10'); 587 | expect($('.pickadate-active:eq(2)')).to.have.text('13'); 588 | }); 589 | 590 | }); 591 | 592 | describe('Disabled dates', function() { 593 | 594 | beforeEach(function() { 595 | compile('
'); 596 | }); 597 | 598 | it("removes the disabled dates from the initial date array", function() { 599 | $scope.date = ['2015-05-07', '2015-05-10', '2015-05-13']; 600 | $scope.disabledDates = ['2015-05-10']; 601 | $scope.$digest(); 602 | 603 | expect($('.pickadate-active').get()).to.have.length(2); 604 | expect($('.pickadate-active:eq(0)')).to.have.text('7'); 605 | expect($('.pickadate-active:eq(1)')).to.have.text('13'); 606 | }); 607 | 608 | it("removes the disabled date if the ngModel is updated and contains disabled dates", function() { 609 | $scope.disabledDates = ['2015-05-10', '2015-05-13']; 610 | $scope.$digest(); 611 | 612 | $scope.date = ['2015-05-07', '2015-05-10', '2015-05-13']; 613 | $scope.$digest(); 614 | 615 | expect($('.pickadate-active').get()).to.have.length(1); 616 | expect($('.pickadate-active:eq(0)')).to.have.text('7'); 617 | }); 618 | 619 | }); 620 | 621 | describe("Don't allow blank date", function() { 622 | beforeEach(function() { 623 | compile('
'); 624 | }); 625 | 626 | it("doesn't remove the last selected date", function() { 627 | $scope.date = ['2015-05-25']; 628 | $scope.$digest(); 629 | browserTrigger($('.pickadate-enabled:contains(25)'), 'click'); 630 | expect($scope.date).to.deep.equal(['2015-05-25']); 631 | }); 632 | 633 | it("removes the selected date when it is not the last one", function() { 634 | $scope.date = ['2015-05-25', '2015-05-20']; 635 | $scope.$digest(); 636 | browserTrigger($('.pickadate-enabled:contains(25)'), 'click'); 637 | expect($scope.date).to.deep.equal(['2015-05-20']); 638 | }); 639 | }); 640 | 641 | describe("Allow blank date", function() { 642 | beforeEach(function() { 643 | compile('
'); 644 | }); 645 | 646 | it("removes the last selected date", function() { 647 | $scope.date = ['2015-05-25']; 648 | $scope.$digest(); 649 | browserTrigger($('.pickadate-enabled:contains(25)'), 'click'); 650 | expect($scope.date).to.be.empty; 651 | }); 652 | }); 653 | 654 | }); 655 | 656 | describe('When used as a modal', function() { 657 | 658 | var inputHtml = '
' + 659 | '' + 660 | '
', 661 | input, form; 662 | 663 | beforeEach(function() { 664 | $scope.date = '2014-05-17'; 665 | $scope.minDate = '2014-01-01'; 666 | compile(inputHtml); 667 | form = element; 668 | input = $('input'); 669 | element = $('.pickadate'); 670 | }); 671 | 672 | it('adds the pickadate-modal class', function() { 673 | expect(element).to.have.class('pickadate-modal'); 674 | }); 675 | 676 | it('renders the datepicker already hidden', function() { 677 | expect(element).to.have.class('ng-hide'); 678 | }); 679 | 680 | it('displays the datepicker when the input is focused', function() { 681 | browserTrigger(input, 'focus'); 682 | expect(element).not.to.have.class('ng-hide'); 683 | }); 684 | 685 | it('hides the datepicker when the user clicks outside the datepicker', function() { 686 | expect(element).to.have.class('ng-hide'); 687 | 688 | browserTrigger(input, 'focus'); 689 | expect(element).not.to.have.class('ng-hide'); 690 | 691 | browserTrigger(document.body, 'click'); 692 | expect(element).to.have.class('ng-hide'); 693 | }); 694 | 695 | it("doesn't hide the datepicker if the calendar is clicked", function() { 696 | expect(element).to.have.class('ng-hide'); 697 | 698 | browserTrigger(input, 'focus'); 699 | expect(element).not.to.have.class('ng-hide'); 700 | 701 | browserTrigger($('.pickadate-centered-heading'), 'click'); 702 | expect(element).not.to.have.class('ng-hide'); 703 | }); 704 | 705 | it("hides the datepicker if a date is selected", function() { 706 | expect(element).to.have.class('ng-hide'); 707 | 708 | browserTrigger(input, 'focus'); 709 | expect(element).not.to.have.class('ng-hide'); 710 | 711 | browserTrigger($('.pickadate-enabled:first'), 'click'); 712 | expect(element).to.have.class('ng-hide'); 713 | }); 714 | 715 | it('sets the input value with the ng-model value', function() { 716 | expect(input).to.have.value('2014-05-17'); 717 | 718 | browserTrigger(input, 'focus'); 719 | browserTrigger($('.pickadate-enabled:first'), 'click'); 720 | 721 | expect(input).to.have.value('2014-05-01'); 722 | 723 | browserTrigger(input, 'focus'); 724 | browserTrigger($('.pickadate-enabled:last'), 'click'); 725 | 726 | expect(input).to.have.value('2014-05-31'); 727 | }); 728 | 729 | describe('and the input value is manually changed', function() { 730 | 731 | it('updates the ng-model with the entered value', function() { 732 | expect($scope.date).to.eq('2014-05-17'); 733 | 734 | input.val('2015-02-20'); 735 | browserTrigger(input, 'change'); 736 | 737 | expect($scope.date).to.eq('2015-02-20'); 738 | }); 739 | 740 | it('sets the ng-model to undefined if the date is not in a valid range', function() { 741 | expect($scope.date).to.eq('2014-05-17'); 742 | expect($scope.dateForm.$error).not.to.have.property('date'); 743 | 744 | input.val('2012-02-20'); 745 | browserTrigger(input, 'change'); 746 | 747 | expect($scope.dateForm.$error).to.have.property('date'); 748 | }); 749 | 750 | it('sets the ng-model to undefined if the date is not valid', function() { 751 | expect($scope.date).to.eq('2014-05-17'); 752 | expect($scope.dateForm.$error).not.to.have.property('date'); 753 | 754 | input.val('2012-02-'); 755 | browserTrigger(input, 'change'); 756 | 757 | expect($scope.dateForm.$error).to.have.property('date'); 758 | }); 759 | 760 | }); 761 | 762 | describe('and the input has already a value', function() { 763 | 764 | function compileModal(value) { 765 | var html = 766 | '
' + 767 | '' + 768 | '
'; 769 | 770 | $scope.minDate = '2014-01-01'; 771 | compile(html); 772 | 773 | form = element; 774 | input = $('input'); 775 | element = $('.pickadate'); 776 | } 777 | 778 | it("sets the ng-model value as the element's value", function() { 779 | $scope.ngModelDate = '2014-05-10'; 780 | compileModal('2015-01-14'); 781 | 782 | expect($scope.ngModelDate).to.eq('2015-01-14'); 783 | }); 784 | 785 | it("sets the ng-model value as undefined if is not in a valid range", function() { 786 | compileModal('2011-01-14'); 787 | expect($scope.ngModelDate).to.be.undefined; 788 | }); 789 | 790 | }); 791 | 792 | }); 793 | 794 | }); 795 | -------------------------------------------------------------------------------- /test/lib/angular-mocks-1.2.21.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license AngularJS v1.2.21 3 | * (c) 2010-2014 Google, Inc. http://angularjs.org 4 | * License: MIT 5 | */ 6 | (function(window, angular, undefined) { 7 | 8 | 'use strict'; 9 | 10 | /** 11 | * @ngdoc object 12 | * @name angular.mock 13 | * @description 14 | * 15 | * Namespace from 'angular-mocks.js' which contains testing related code. 16 | */ 17 | angular.mock = {}; 18 | 19 | /** 20 | * ! This is a private undocumented service ! 21 | * 22 | * @name $browser 23 | * 24 | * @description 25 | * This service is a mock implementation of {@link ng.$browser}. It provides fake 26 | * implementation for commonly used browser apis that are hard to test, e.g. setTimeout, xhr, 27 | * cookies, etc... 28 | * 29 | * The api of this service is the same as that of the real {@link ng.$browser $browser}, except 30 | * that there are several helper methods available which can be used in tests. 31 | */ 32 | angular.mock.$BrowserProvider = function() { 33 | this.$get = function() { 34 | return new angular.mock.$Browser(); 35 | }; 36 | }; 37 | 38 | angular.mock.$Browser = function() { 39 | var self = this; 40 | 41 | this.isMock = true; 42 | self.$$url = "http://server/"; 43 | self.$$lastUrl = self.$$url; // used by url polling fn 44 | self.pollFns = []; 45 | 46 | // TODO(vojta): remove this temporary api 47 | self.$$completeOutstandingRequest = angular.noop; 48 | self.$$incOutstandingRequestCount = angular.noop; 49 | 50 | 51 | // register url polling fn 52 | 53 | self.onUrlChange = function(listener) { 54 | self.pollFns.push( 55 | function() { 56 | if (self.$$lastUrl != self.$$url) { 57 | self.$$lastUrl = self.$$url; 58 | listener(self.$$url); 59 | } 60 | } 61 | ); 62 | 63 | return listener; 64 | }; 65 | 66 | self.cookieHash = {}; 67 | self.lastCookieHash = {}; 68 | self.deferredFns = []; 69 | self.deferredNextId = 0; 70 | 71 | self.defer = function(fn, delay) { 72 | delay = delay || 0; 73 | self.deferredFns.push({time:(self.defer.now + delay), fn:fn, id: self.deferredNextId}); 74 | self.deferredFns.sort(function(a,b){ return a.time - b.time;}); 75 | return self.deferredNextId++; 76 | }; 77 | 78 | 79 | /** 80 | * @name $browser#defer.now 81 | * 82 | * @description 83 | * Current milliseconds mock time. 84 | */ 85 | self.defer.now = 0; 86 | 87 | 88 | self.defer.cancel = function(deferId) { 89 | var fnIndex; 90 | 91 | angular.forEach(self.deferredFns, function(fn, index) { 92 | if (fn.id === deferId) fnIndex = index; 93 | }); 94 | 95 | if (fnIndex !== undefined) { 96 | self.deferredFns.splice(fnIndex, 1); 97 | return true; 98 | } 99 | 100 | return false; 101 | }; 102 | 103 | 104 | /** 105 | * @name $browser#defer.flush 106 | * 107 | * @description 108 | * Flushes all pending requests and executes the defer callbacks. 109 | * 110 | * @param {number=} number of milliseconds to flush. See {@link #defer.now} 111 | */ 112 | self.defer.flush = function(delay) { 113 | if (angular.isDefined(delay)) { 114 | self.defer.now += delay; 115 | } else { 116 | if (self.deferredFns.length) { 117 | self.defer.now = self.deferredFns[self.deferredFns.length-1].time; 118 | } else { 119 | throw new Error('No deferred tasks to be flushed'); 120 | } 121 | } 122 | 123 | while (self.deferredFns.length && self.deferredFns[0].time <= self.defer.now) { 124 | self.deferredFns.shift().fn(); 125 | } 126 | }; 127 | 128 | self.$$baseHref = ''; 129 | self.baseHref = function() { 130 | return this.$$baseHref; 131 | }; 132 | }; 133 | angular.mock.$Browser.prototype = { 134 | 135 | /** 136 | * @name $browser#poll 137 | * 138 | * @description 139 | * run all fns in pollFns 140 | */ 141 | poll: function poll() { 142 | angular.forEach(this.pollFns, function(pollFn){ 143 | pollFn(); 144 | }); 145 | }, 146 | 147 | addPollFn: function(pollFn) { 148 | this.pollFns.push(pollFn); 149 | return pollFn; 150 | }, 151 | 152 | url: function(url, replace) { 153 | if (url) { 154 | this.$$url = url; 155 | return this; 156 | } 157 | 158 | return this.$$url; 159 | }, 160 | 161 | cookies: function(name, value) { 162 | if (name) { 163 | if (angular.isUndefined(value)) { 164 | delete this.cookieHash[name]; 165 | } else { 166 | if (angular.isString(value) && //strings only 167 | value.length <= 4096) { //strict cookie storage limits 168 | this.cookieHash[name] = value; 169 | } 170 | } 171 | } else { 172 | if (!angular.equals(this.cookieHash, this.lastCookieHash)) { 173 | this.lastCookieHash = angular.copy(this.cookieHash); 174 | this.cookieHash = angular.copy(this.cookieHash); 175 | } 176 | return this.cookieHash; 177 | } 178 | }, 179 | 180 | notifyWhenNoOutstandingRequests: function(fn) { 181 | fn(); 182 | } 183 | }; 184 | 185 | 186 | /** 187 | * @ngdoc provider 188 | * @name $exceptionHandlerProvider 189 | * 190 | * @description 191 | * Configures the mock implementation of {@link ng.$exceptionHandler} to rethrow or to log errors 192 | * passed into the `$exceptionHandler`. 193 | */ 194 | 195 | /** 196 | * @ngdoc service 197 | * @name $exceptionHandler 198 | * 199 | * @description 200 | * Mock implementation of {@link ng.$exceptionHandler} that rethrows or logs errors passed 201 | * into it. See {@link ngMock.$exceptionHandlerProvider $exceptionHandlerProvider} for configuration 202 | * information. 203 | * 204 | * 205 | * ```js 206 | * describe('$exceptionHandlerProvider', function() { 207 | * 208 | * it('should capture log messages and exceptions', function() { 209 | * 210 | * module(function($exceptionHandlerProvider) { 211 | * $exceptionHandlerProvider.mode('log'); 212 | * }); 213 | * 214 | * inject(function($log, $exceptionHandler, $timeout) { 215 | * $timeout(function() { $log.log(1); }); 216 | * $timeout(function() { $log.log(2); throw 'banana peel'; }); 217 | * $timeout(function() { $log.log(3); }); 218 | * expect($exceptionHandler.errors).toEqual([]); 219 | * expect($log.assertEmpty()); 220 | * $timeout.flush(); 221 | * expect($exceptionHandler.errors).toEqual(['banana peel']); 222 | * expect($log.log.logs).toEqual([[1], [2], [3]]); 223 | * }); 224 | * }); 225 | * }); 226 | * ``` 227 | */ 228 | 229 | angular.mock.$ExceptionHandlerProvider = function() { 230 | var handler; 231 | 232 | /** 233 | * @ngdoc method 234 | * @name $exceptionHandlerProvider#mode 235 | * 236 | * @description 237 | * Sets the logging mode. 238 | * 239 | * @param {string} mode Mode of operation, defaults to `rethrow`. 240 | * 241 | * - `rethrow`: If any errors are passed into the handler in tests, it typically 242 | * means that there is a bug in the application or test, so this mock will 243 | * make these tests fail. 244 | * - `log`: Sometimes it is desirable to test that an error is thrown, for this case the `log` 245 | * mode stores an array of errors in `$exceptionHandler.errors`, to allow later 246 | * assertion of them. See {@link ngMock.$log#assertEmpty assertEmpty()} and 247 | * {@link ngMock.$log#reset reset()} 248 | */ 249 | this.mode = function(mode) { 250 | switch(mode) { 251 | case 'rethrow': 252 | handler = function(e) { 253 | throw e; 254 | }; 255 | break; 256 | case 'log': 257 | var errors = []; 258 | 259 | handler = function(e) { 260 | if (arguments.length == 1) { 261 | errors.push(e); 262 | } else { 263 | errors.push([].slice.call(arguments, 0)); 264 | } 265 | }; 266 | 267 | handler.errors = errors; 268 | break; 269 | default: 270 | throw new Error("Unknown mode '" + mode + "', only 'log'/'rethrow' modes are allowed!"); 271 | } 272 | }; 273 | 274 | this.$get = function() { 275 | return handler; 276 | }; 277 | 278 | this.mode('rethrow'); 279 | }; 280 | 281 | 282 | /** 283 | * @ngdoc service 284 | * @name $log 285 | * 286 | * @description 287 | * Mock implementation of {@link ng.$log} that gathers all logged messages in arrays 288 | * (one array per logging level). These arrays are exposed as `logs` property of each of the 289 | * level-specific log function, e.g. for level `error` the array is exposed as `$log.error.logs`. 290 | * 291 | */ 292 | angular.mock.$LogProvider = function() { 293 | var debug = true; 294 | 295 | function concat(array1, array2, index) { 296 | return array1.concat(Array.prototype.slice.call(array2, index)); 297 | } 298 | 299 | this.debugEnabled = function(flag) { 300 | if (angular.isDefined(flag)) { 301 | debug = flag; 302 | return this; 303 | } else { 304 | return debug; 305 | } 306 | }; 307 | 308 | this.$get = function () { 309 | var $log = { 310 | log: function() { $log.log.logs.push(concat([], arguments, 0)); }, 311 | warn: function() { $log.warn.logs.push(concat([], arguments, 0)); }, 312 | info: function() { $log.info.logs.push(concat([], arguments, 0)); }, 313 | error: function() { $log.error.logs.push(concat([], arguments, 0)); }, 314 | debug: function() { 315 | if (debug) { 316 | $log.debug.logs.push(concat([], arguments, 0)); 317 | } 318 | } 319 | }; 320 | 321 | /** 322 | * @ngdoc method 323 | * @name $log#reset 324 | * 325 | * @description 326 | * Reset all of the logging arrays to empty. 327 | */ 328 | $log.reset = function () { 329 | /** 330 | * @ngdoc property 331 | * @name $log#log.logs 332 | * 333 | * @description 334 | * Array of messages logged using {@link ngMock.$log#log}. 335 | * 336 | * @example 337 | * ```js 338 | * $log.log('Some Log'); 339 | * var first = $log.log.logs.unshift(); 340 | * ``` 341 | */ 342 | $log.log.logs = []; 343 | /** 344 | * @ngdoc property 345 | * @name $log#info.logs 346 | * 347 | * @description 348 | * Array of messages logged using {@link ngMock.$log#info}. 349 | * 350 | * @example 351 | * ```js 352 | * $log.info('Some Info'); 353 | * var first = $log.info.logs.unshift(); 354 | * ``` 355 | */ 356 | $log.info.logs = []; 357 | /** 358 | * @ngdoc property 359 | * @name $log#warn.logs 360 | * 361 | * @description 362 | * Array of messages logged using {@link ngMock.$log#warn}. 363 | * 364 | * @example 365 | * ```js 366 | * $log.warn('Some Warning'); 367 | * var first = $log.warn.logs.unshift(); 368 | * ``` 369 | */ 370 | $log.warn.logs = []; 371 | /** 372 | * @ngdoc property 373 | * @name $log#error.logs 374 | * 375 | * @description 376 | * Array of messages logged using {@link ngMock.$log#error}. 377 | * 378 | * @example 379 | * ```js 380 | * $log.error('Some Error'); 381 | * var first = $log.error.logs.unshift(); 382 | * ``` 383 | */ 384 | $log.error.logs = []; 385 | /** 386 | * @ngdoc property 387 | * @name $log#debug.logs 388 | * 389 | * @description 390 | * Array of messages logged using {@link ngMock.$log#debug}. 391 | * 392 | * @example 393 | * ```js 394 | * $log.debug('Some Error'); 395 | * var first = $log.debug.logs.unshift(); 396 | * ``` 397 | */ 398 | $log.debug.logs = []; 399 | }; 400 | 401 | /** 402 | * @ngdoc method 403 | * @name $log#assertEmpty 404 | * 405 | * @description 406 | * Assert that the all of the logging methods have no logged messages. If messages present, an 407 | * exception is thrown. 408 | */ 409 | $log.assertEmpty = function() { 410 | var errors = []; 411 | angular.forEach(['error', 'warn', 'info', 'log', 'debug'], function(logLevel) { 412 | angular.forEach($log[logLevel].logs, function(log) { 413 | angular.forEach(log, function (logItem) { 414 | errors.push('MOCK $log (' + logLevel + '): ' + String(logItem) + '\n' + 415 | (logItem.stack || '')); 416 | }); 417 | }); 418 | }); 419 | if (errors.length) { 420 | errors.unshift("Expected $log to be empty! Either a message was logged unexpectedly, or "+ 421 | "an expected log message was not checked and removed:"); 422 | errors.push(''); 423 | throw new Error(errors.join('\n---------\n')); 424 | } 425 | }; 426 | 427 | $log.reset(); 428 | return $log; 429 | }; 430 | }; 431 | 432 | 433 | /** 434 | * @ngdoc service 435 | * @name $interval 436 | * 437 | * @description 438 | * Mock implementation of the $interval service. 439 | * 440 | * Use {@link ngMock.$interval#flush `$interval.flush(millis)`} to 441 | * move forward by `millis` milliseconds and trigger any functions scheduled to run in that 442 | * time. 443 | * 444 | * @param {function()} fn A function that should be called repeatedly. 445 | * @param {number} delay Number of milliseconds between each function call. 446 | * @param {number=} [count=0] Number of times to repeat. If not set, or 0, will repeat 447 | * indefinitely. 448 | * @param {boolean=} [invokeApply=true] If set to `false` skips model dirty checking, otherwise 449 | * will invoke `fn` within the {@link ng.$rootScope.Scope#$apply $apply} block. 450 | * @returns {promise} A promise which will be notified on each iteration. 451 | */ 452 | angular.mock.$IntervalProvider = function() { 453 | this.$get = ['$rootScope', '$q', 454 | function($rootScope, $q) { 455 | var repeatFns = [], 456 | nextRepeatId = 0, 457 | now = 0; 458 | 459 | var $interval = function(fn, delay, count, invokeApply) { 460 | var deferred = $q.defer(), 461 | promise = deferred.promise, 462 | iteration = 0, 463 | skipApply = (angular.isDefined(invokeApply) && !invokeApply); 464 | 465 | count = (angular.isDefined(count)) ? count : 0; 466 | promise.then(null, null, fn); 467 | 468 | promise.$$intervalId = nextRepeatId; 469 | 470 | function tick() { 471 | deferred.notify(iteration++); 472 | 473 | if (count > 0 && iteration >= count) { 474 | var fnIndex; 475 | deferred.resolve(iteration); 476 | 477 | angular.forEach(repeatFns, function(fn, index) { 478 | if (fn.id === promise.$$intervalId) fnIndex = index; 479 | }); 480 | 481 | if (fnIndex !== undefined) { 482 | repeatFns.splice(fnIndex, 1); 483 | } 484 | } 485 | 486 | if (!skipApply) $rootScope.$apply(); 487 | } 488 | 489 | repeatFns.push({ 490 | nextTime:(now + delay), 491 | delay: delay, 492 | fn: tick, 493 | id: nextRepeatId, 494 | deferred: deferred 495 | }); 496 | repeatFns.sort(function(a,b){ return a.nextTime - b.nextTime;}); 497 | 498 | nextRepeatId++; 499 | return promise; 500 | }; 501 | /** 502 | * @ngdoc method 503 | * @name $interval#cancel 504 | * 505 | * @description 506 | * Cancels a task associated with the `promise`. 507 | * 508 | * @param {promise} promise A promise from calling the `$interval` function. 509 | * @returns {boolean} Returns `true` if the task was successfully cancelled. 510 | */ 511 | $interval.cancel = function(promise) { 512 | if(!promise) return false; 513 | var fnIndex; 514 | 515 | angular.forEach(repeatFns, function(fn, index) { 516 | if (fn.id === promise.$$intervalId) fnIndex = index; 517 | }); 518 | 519 | if (fnIndex !== undefined) { 520 | repeatFns[fnIndex].deferred.reject('canceled'); 521 | repeatFns.splice(fnIndex, 1); 522 | return true; 523 | } 524 | 525 | return false; 526 | }; 527 | 528 | /** 529 | * @ngdoc method 530 | * @name $interval#flush 531 | * @description 532 | * 533 | * Runs interval tasks scheduled to be run in the next `millis` milliseconds. 534 | * 535 | * @param {number=} millis maximum timeout amount to flush up until. 536 | * 537 | * @return {number} The amount of time moved forward. 538 | */ 539 | $interval.flush = function(millis) { 540 | now += millis; 541 | while (repeatFns.length && repeatFns[0].nextTime <= now) { 542 | var task = repeatFns[0]; 543 | task.fn(); 544 | task.nextTime += task.delay; 545 | repeatFns.sort(function(a,b){ return a.nextTime - b.nextTime;}); 546 | } 547 | return millis; 548 | }; 549 | 550 | return $interval; 551 | }]; 552 | }; 553 | 554 | 555 | /* jshint -W101 */ 556 | /* The R_ISO8061_STR regex is never going to fit into the 100 char limit! 557 | * This directive should go inside the anonymous function but a bug in JSHint means that it would 558 | * not be enacted early enough to prevent the warning. 559 | */ 560 | var R_ISO8061_STR = /^(\d{4})-?(\d\d)-?(\d\d)(?:T(\d\d)(?:\:?(\d\d)(?:\:?(\d\d)(?:\.(\d{3}))?)?)?(Z|([+-])(\d\d):?(\d\d)))?$/; 561 | 562 | function jsonStringToDate(string) { 563 | var match; 564 | if (match = string.match(R_ISO8061_STR)) { 565 | var date = new Date(0), 566 | tzHour = 0, 567 | tzMin = 0; 568 | if (match[9]) { 569 | tzHour = int(match[9] + match[10]); 570 | tzMin = int(match[9] + match[11]); 571 | } 572 | date.setUTCFullYear(int(match[1]), int(match[2]) - 1, int(match[3])); 573 | date.setUTCHours(int(match[4]||0) - tzHour, 574 | int(match[5]||0) - tzMin, 575 | int(match[6]||0), 576 | int(match[7]||0)); 577 | return date; 578 | } 579 | return string; 580 | } 581 | 582 | function int(str) { 583 | return parseInt(str, 10); 584 | } 585 | 586 | function padNumber(num, digits, trim) { 587 | var neg = ''; 588 | if (num < 0) { 589 | neg = '-'; 590 | num = -num; 591 | } 592 | num = '' + num; 593 | while(num.length < digits) num = '0' + num; 594 | if (trim) 595 | num = num.substr(num.length - digits); 596 | return neg + num; 597 | } 598 | 599 | 600 | /** 601 | * @ngdoc type 602 | * @name angular.mock.TzDate 603 | * @description 604 | * 605 | * *NOTE*: this is not an injectable instance, just a globally available mock class of `Date`. 606 | * 607 | * Mock of the Date type which has its timezone specified via constructor arg. 608 | * 609 | * The main purpose is to create Date-like instances with timezone fixed to the specified timezone 610 | * offset, so that we can test code that depends on local timezone settings without dependency on 611 | * the time zone settings of the machine where the code is running. 612 | * 613 | * @param {number} offset Offset of the *desired* timezone in hours (fractions will be honored) 614 | * @param {(number|string)} timestamp Timestamp representing the desired time in *UTC* 615 | * 616 | * @example 617 | * !!!! WARNING !!!!! 618 | * This is not a complete Date object so only methods that were implemented can be called safely. 619 | * To make matters worse, TzDate instances inherit stuff from Date via a prototype. 620 | * 621 | * We do our best to intercept calls to "unimplemented" methods, but since the list of methods is 622 | * incomplete we might be missing some non-standard methods. This can result in errors like: 623 | * "Date.prototype.foo called on incompatible Object". 624 | * 625 | * ```js 626 | * var newYearInBratislava = new TzDate(-1, '2009-12-31T23:00:00Z'); 627 | * newYearInBratislava.getTimezoneOffset() => -60; 628 | * newYearInBratislava.getFullYear() => 2010; 629 | * newYearInBratislava.getMonth() => 0; 630 | * newYearInBratislava.getDate() => 1; 631 | * newYearInBratislava.getHours() => 0; 632 | * newYearInBratislava.getMinutes() => 0; 633 | * newYearInBratislava.getSeconds() => 0; 634 | * ``` 635 | * 636 | */ 637 | angular.mock.TzDate = function (offset, timestamp) { 638 | var self = new Date(0); 639 | if (angular.isString(timestamp)) { 640 | var tsStr = timestamp; 641 | 642 | self.origDate = jsonStringToDate(timestamp); 643 | 644 | timestamp = self.origDate.getTime(); 645 | if (isNaN(timestamp)) 646 | throw { 647 | name: "Illegal Argument", 648 | message: "Arg '" + tsStr + "' passed into TzDate constructor is not a valid date string" 649 | }; 650 | } else { 651 | self.origDate = new Date(timestamp); 652 | } 653 | 654 | var localOffset = new Date(timestamp).getTimezoneOffset(); 655 | self.offsetDiff = localOffset*60*1000 - offset*1000*60*60; 656 | self.date = new Date(timestamp + self.offsetDiff); 657 | 658 | self.getTime = function() { 659 | return self.date.getTime() - self.offsetDiff; 660 | }; 661 | 662 | self.toLocaleDateString = function() { 663 | return self.date.toLocaleDateString(); 664 | }; 665 | 666 | self.getFullYear = function() { 667 | return self.date.getFullYear(); 668 | }; 669 | 670 | self.getMonth = function() { 671 | return self.date.getMonth(); 672 | }; 673 | 674 | self.getDate = function() { 675 | return self.date.getDate(); 676 | }; 677 | 678 | self.getHours = function() { 679 | return self.date.getHours(); 680 | }; 681 | 682 | self.getMinutes = function() { 683 | return self.date.getMinutes(); 684 | }; 685 | 686 | self.getSeconds = function() { 687 | return self.date.getSeconds(); 688 | }; 689 | 690 | self.getMilliseconds = function() { 691 | return self.date.getMilliseconds(); 692 | }; 693 | 694 | self.getTimezoneOffset = function() { 695 | return offset * 60; 696 | }; 697 | 698 | self.getUTCFullYear = function() { 699 | return self.origDate.getUTCFullYear(); 700 | }; 701 | 702 | self.getUTCMonth = function() { 703 | return self.origDate.getUTCMonth(); 704 | }; 705 | 706 | self.getUTCDate = function() { 707 | return self.origDate.getUTCDate(); 708 | }; 709 | 710 | self.getUTCHours = function() { 711 | return self.origDate.getUTCHours(); 712 | }; 713 | 714 | self.getUTCMinutes = function() { 715 | return self.origDate.getUTCMinutes(); 716 | }; 717 | 718 | self.getUTCSeconds = function() { 719 | return self.origDate.getUTCSeconds(); 720 | }; 721 | 722 | self.getUTCMilliseconds = function() { 723 | return self.origDate.getUTCMilliseconds(); 724 | }; 725 | 726 | self.getDay = function() { 727 | return self.date.getDay(); 728 | }; 729 | 730 | // provide this method only on browsers that already have it 731 | if (self.toISOString) { 732 | self.toISOString = function() { 733 | return padNumber(self.origDate.getUTCFullYear(), 4) + '-' + 734 | padNumber(self.origDate.getUTCMonth() + 1, 2) + '-' + 735 | padNumber(self.origDate.getUTCDate(), 2) + 'T' + 736 | padNumber(self.origDate.getUTCHours(), 2) + ':' + 737 | padNumber(self.origDate.getUTCMinutes(), 2) + ':' + 738 | padNumber(self.origDate.getUTCSeconds(), 2) + '.' + 739 | padNumber(self.origDate.getUTCMilliseconds(), 3) + 'Z'; 740 | }; 741 | } 742 | 743 | //hide all methods not implemented in this mock that the Date prototype exposes 744 | var unimplementedMethods = ['getUTCDay', 745 | 'getYear', 'setDate', 'setFullYear', 'setHours', 'setMilliseconds', 746 | 'setMinutes', 'setMonth', 'setSeconds', 'setTime', 'setUTCDate', 'setUTCFullYear', 747 | 'setUTCHours', 'setUTCMilliseconds', 'setUTCMinutes', 'setUTCMonth', 'setUTCSeconds', 748 | 'setYear', 'toDateString', 'toGMTString', 'toJSON', 'toLocaleFormat', 'toLocaleString', 749 | 'toLocaleTimeString', 'toSource', 'toString', 'toTimeString', 'toUTCString', 'valueOf']; 750 | 751 | angular.forEach(unimplementedMethods, function(methodName) { 752 | self[methodName] = function() { 753 | throw new Error("Method '" + methodName + "' is not implemented in the TzDate mock"); 754 | }; 755 | }); 756 | 757 | return self; 758 | }; 759 | 760 | //make "tzDateInstance instanceof Date" return true 761 | angular.mock.TzDate.prototype = Date.prototype; 762 | /* jshint +W101 */ 763 | 764 | angular.mock.animate = angular.module('ngAnimateMock', ['ng']) 765 | 766 | .config(['$provide', function($provide) { 767 | 768 | var reflowQueue = []; 769 | $provide.value('$$animateReflow', function(fn) { 770 | var index = reflowQueue.length; 771 | reflowQueue.push(fn); 772 | return function cancel() { 773 | reflowQueue.splice(index, 1); 774 | }; 775 | }); 776 | 777 | $provide.decorator('$animate', function($delegate, $$asyncCallback) { 778 | var animate = { 779 | queue : [], 780 | enabled : $delegate.enabled, 781 | triggerCallbacks : function() { 782 | $$asyncCallback.flush(); 783 | }, 784 | triggerReflow : function() { 785 | angular.forEach(reflowQueue, function(fn) { 786 | fn(); 787 | }); 788 | reflowQueue = []; 789 | } 790 | }; 791 | 792 | angular.forEach( 793 | ['enter','leave','move','addClass','removeClass','setClass'], function(method) { 794 | animate[method] = function() { 795 | animate.queue.push({ 796 | event : method, 797 | element : arguments[0], 798 | args : arguments 799 | }); 800 | $delegate[method].apply($delegate, arguments); 801 | }; 802 | }); 803 | 804 | return animate; 805 | }); 806 | 807 | }]); 808 | 809 | 810 | /** 811 | * @ngdoc function 812 | * @name angular.mock.dump 813 | * @description 814 | * 815 | * *NOTE*: this is not an injectable instance, just a globally available function. 816 | * 817 | * Method for serializing common angular objects (scope, elements, etc..) into strings, useful for 818 | * debugging. 819 | * 820 | * This method is also available on window, where it can be used to display objects on debug 821 | * console. 822 | * 823 | * @param {*} object - any object to turn into string. 824 | * @return {string} a serialized string of the argument 825 | */ 826 | angular.mock.dump = function(object) { 827 | return serialize(object); 828 | 829 | function serialize(object) { 830 | var out; 831 | 832 | if (angular.isElement(object)) { 833 | object = angular.element(object); 834 | out = angular.element('
'); 835 | angular.forEach(object, function(element) { 836 | out.append(angular.element(element).clone()); 837 | }); 838 | out = out.html(); 839 | } else if (angular.isArray(object)) { 840 | out = []; 841 | angular.forEach(object, function(o) { 842 | out.push(serialize(o)); 843 | }); 844 | out = '[ ' + out.join(', ') + ' ]'; 845 | } else if (angular.isObject(object)) { 846 | if (angular.isFunction(object.$eval) && angular.isFunction(object.$apply)) { 847 | out = serializeScope(object); 848 | } else if (object instanceof Error) { 849 | out = object.stack || ('' + object.name + ': ' + object.message); 850 | } else { 851 | // TODO(i): this prevents methods being logged, 852 | // we should have a better way to serialize objects 853 | out = angular.toJson(object, true); 854 | } 855 | } else { 856 | out = String(object); 857 | } 858 | 859 | return out; 860 | } 861 | 862 | function serializeScope(scope, offset) { 863 | offset = offset || ' '; 864 | var log = [offset + 'Scope(' + scope.$id + '): {']; 865 | for ( var key in scope ) { 866 | if (Object.prototype.hasOwnProperty.call(scope, key) && !key.match(/^(\$|this)/)) { 867 | log.push(' ' + key + ': ' + angular.toJson(scope[key])); 868 | } 869 | } 870 | var child = scope.$$childHead; 871 | while(child) { 872 | log.push(serializeScope(child, offset + ' ')); 873 | child = child.$$nextSibling; 874 | } 875 | log.push('}'); 876 | return log.join('\n' + offset); 877 | } 878 | }; 879 | 880 | /** 881 | * @ngdoc service 882 | * @name $httpBackend 883 | * @description 884 | * Fake HTTP backend implementation suitable for unit testing applications that use the 885 | * {@link ng.$http $http service}. 886 | * 887 | * *Note*: For fake HTTP backend implementation suitable for end-to-end testing or backend-less 888 | * development please see {@link ngMockE2E.$httpBackend e2e $httpBackend mock}. 889 | * 890 | * During unit testing, we want our unit tests to run quickly and have no external dependencies so 891 | * we don’t want to send [XHR](https://developer.mozilla.org/en/xmlhttprequest) or 892 | * [JSONP](http://en.wikipedia.org/wiki/JSONP) requests to a real server. All we really need is 893 | * to verify whether a certain request has been sent or not, or alternatively just let the 894 | * application make requests, respond with pre-trained responses and assert that the end result is 895 | * what we expect it to be. 896 | * 897 | * This mock implementation can be used to respond with static or dynamic responses via the 898 | * `expect` and `when` apis and their shortcuts (`expectGET`, `whenPOST`, etc). 899 | * 900 | * When an Angular application needs some data from a server, it calls the $http service, which 901 | * sends the request to a real server using $httpBackend service. With dependency injection, it is 902 | * easy to inject $httpBackend mock (which has the same API as $httpBackend) and use it to verify 903 | * the requests and respond with some testing data without sending a request to a real server. 904 | * 905 | * There are two ways to specify what test data should be returned as http responses by the mock 906 | * backend when the code under test makes http requests: 907 | * 908 | * - `$httpBackend.expect` - specifies a request expectation 909 | * - `$httpBackend.when` - specifies a backend definition 910 | * 911 | * 912 | * # Request Expectations vs Backend Definitions 913 | * 914 | * Request expectations provide a way to make assertions about requests made by the application and 915 | * to define responses for those requests. The test will fail if the expected requests are not made 916 | * or they are made in the wrong order. 917 | * 918 | * Backend definitions allow you to define a fake backend for your application which doesn't assert 919 | * if a particular request was made or not, it just returns a trained response if a request is made. 920 | * The test will pass whether or not the request gets made during testing. 921 | * 922 | * 923 | * 924 | * 925 | * 926 | * 927 | * 928 | * 929 | * 930 | * 931 | * 932 | * 933 | * 934 | * 935 | * 936 | * 937 | * 938 | * 939 | * 940 | * 941 | * 942 | * 943 | * 944 | * 945 | * 946 | * 947 | * 948 | * 949 | * 950 | * 951 | * 952 | * 953 | * 954 | * 955 | *
Request expectationsBackend definitions
Syntax.expect(...).respond(...).when(...).respond(...)
Typical usagestrict unit testsloose (black-box) unit testing
Fulfills multiple requestsNOYES
Order of requests mattersYESNO
Request requiredYESNO
Response requiredoptional (see below)YES
956 | * 957 | * In cases where both backend definitions and request expectations are specified during unit 958 | * testing, the request expectations are evaluated first. 959 | * 960 | * If a request expectation has no response specified, the algorithm will search your backend 961 | * definitions for an appropriate response. 962 | * 963 | * If a request didn't match any expectation or if the expectation doesn't have the response 964 | * defined, the backend definitions are evaluated in sequential order to see if any of them match 965 | * the request. The response from the first matched definition is returned. 966 | * 967 | * 968 | * # Flushing HTTP requests 969 | * 970 | * The $httpBackend used in production always responds to requests asynchronously. If we preserved 971 | * this behavior in unit testing, we'd have to create async unit tests, which are hard to write, 972 | * to follow and to maintain. But neither can the testing mock respond synchronously; that would 973 | * change the execution of the code under test. For this reason, the mock $httpBackend has a 974 | * `flush()` method, which allows the test to explicitly flush pending requests. This preserves 975 | * the async api of the backend, while allowing the test to execute synchronously. 976 | * 977 | * 978 | * # Unit testing with mock $httpBackend 979 | * The following code shows how to setup and use the mock backend when unit testing a controller. 980 | * First we create the controller under test: 981 | * 982 | ```js 983 | // The controller code 984 | function MyController($scope, $http) { 985 | var authToken; 986 | 987 | $http.get('/auth.py').success(function(data, status, headers) { 988 | authToken = headers('A-Token'); 989 | $scope.user = data; 990 | }); 991 | 992 | $scope.saveMessage = function(message) { 993 | var headers = { 'Authorization': authToken }; 994 | $scope.status = 'Saving...'; 995 | 996 | $http.post('/add-msg.py', message, { headers: headers } ).success(function(response) { 997 | $scope.status = ''; 998 | }).error(function() { 999 | $scope.status = 'ERROR!'; 1000 | }); 1001 | }; 1002 | } 1003 | ``` 1004 | * 1005 | * Now we setup the mock backend and create the test specs: 1006 | * 1007 | ```js 1008 | // testing controller 1009 | describe('MyController', function() { 1010 | var $httpBackend, $rootScope, createController; 1011 | 1012 | beforeEach(inject(function($injector) { 1013 | // Set up the mock http service responses 1014 | $httpBackend = $injector.get('$httpBackend'); 1015 | // backend definition common for all tests 1016 | $httpBackend.when('GET', '/auth.py').respond({userId: 'userX'}, {'A-Token': 'xxx'}); 1017 | 1018 | // Get hold of a scope (i.e. the root scope) 1019 | $rootScope = $injector.get('$rootScope'); 1020 | // The $controller service is used to create instances of controllers 1021 | var $controller = $injector.get('$controller'); 1022 | 1023 | createController = function() { 1024 | return $controller('MyController', {'$scope' : $rootScope }); 1025 | }; 1026 | })); 1027 | 1028 | 1029 | afterEach(function() { 1030 | $httpBackend.verifyNoOutstandingExpectation(); 1031 | $httpBackend.verifyNoOutstandingRequest(); 1032 | }); 1033 | 1034 | 1035 | it('should fetch authentication token', function() { 1036 | $httpBackend.expectGET('/auth.py'); 1037 | var controller = createController(); 1038 | $httpBackend.flush(); 1039 | }); 1040 | 1041 | 1042 | it('should send msg to server', function() { 1043 | var controller = createController(); 1044 | $httpBackend.flush(); 1045 | 1046 | // now you don’t care about the authentication, but 1047 | // the controller will still send the request and 1048 | // $httpBackend will respond without you having to 1049 | // specify the expectation and response for this request 1050 | 1051 | $httpBackend.expectPOST('/add-msg.py', 'message content').respond(201, ''); 1052 | $rootScope.saveMessage('message content'); 1053 | expect($rootScope.status).toBe('Saving...'); 1054 | $httpBackend.flush(); 1055 | expect($rootScope.status).toBe(''); 1056 | }); 1057 | 1058 | 1059 | it('should send auth header', function() { 1060 | var controller = createController(); 1061 | $httpBackend.flush(); 1062 | 1063 | $httpBackend.expectPOST('/add-msg.py', undefined, function(headers) { 1064 | // check if the header was send, if it wasn't the expectation won't 1065 | // match the request and the test will fail 1066 | return headers['Authorization'] == 'xxx'; 1067 | }).respond(201, ''); 1068 | 1069 | $rootScope.saveMessage('whatever'); 1070 | $httpBackend.flush(); 1071 | }); 1072 | }); 1073 | ``` 1074 | */ 1075 | angular.mock.$HttpBackendProvider = function() { 1076 | this.$get = ['$rootScope', createHttpBackendMock]; 1077 | }; 1078 | 1079 | /** 1080 | * General factory function for $httpBackend mock. 1081 | * Returns instance for unit testing (when no arguments specified): 1082 | * - passing through is disabled 1083 | * - auto flushing is disabled 1084 | * 1085 | * Returns instance for e2e testing (when `$delegate` and `$browser` specified): 1086 | * - passing through (delegating request to real backend) is enabled 1087 | * - auto flushing is enabled 1088 | * 1089 | * @param {Object=} $delegate Real $httpBackend instance (allow passing through if specified) 1090 | * @param {Object=} $browser Auto-flushing enabled if specified 1091 | * @return {Object} Instance of $httpBackend mock 1092 | */ 1093 | function createHttpBackendMock($rootScope, $delegate, $browser) { 1094 | var definitions = [], 1095 | expectations = [], 1096 | responses = [], 1097 | responsesPush = angular.bind(responses, responses.push), 1098 | copy = angular.copy; 1099 | 1100 | function createResponse(status, data, headers, statusText) { 1101 | if (angular.isFunction(status)) return status; 1102 | 1103 | return function() { 1104 | return angular.isNumber(status) 1105 | ? [status, data, headers, statusText] 1106 | : [200, status, data]; 1107 | }; 1108 | } 1109 | 1110 | // TODO(vojta): change params to: method, url, data, headers, callback 1111 | function $httpBackend(method, url, data, callback, headers, timeout, withCredentials) { 1112 | var xhr = new MockXhr(), 1113 | expectation = expectations[0], 1114 | wasExpected = false; 1115 | 1116 | function prettyPrint(data) { 1117 | return (angular.isString(data) || angular.isFunction(data) || data instanceof RegExp) 1118 | ? data 1119 | : angular.toJson(data); 1120 | } 1121 | 1122 | function wrapResponse(wrapped) { 1123 | if (!$browser && timeout && timeout.then) timeout.then(handleTimeout); 1124 | 1125 | return handleResponse; 1126 | 1127 | function handleResponse() { 1128 | var response = wrapped.response(method, url, data, headers); 1129 | xhr.$$respHeaders = response[2]; 1130 | callback(copy(response[0]), copy(response[1]), xhr.getAllResponseHeaders(), 1131 | copy(response[3] || '')); 1132 | } 1133 | 1134 | function handleTimeout() { 1135 | for (var i = 0, ii = responses.length; i < ii; i++) { 1136 | if (responses[i] === handleResponse) { 1137 | responses.splice(i, 1); 1138 | callback(-1, undefined, ''); 1139 | break; 1140 | } 1141 | } 1142 | } 1143 | } 1144 | 1145 | if (expectation && expectation.match(method, url)) { 1146 | if (!expectation.matchData(data)) 1147 | throw new Error('Expected ' + expectation + ' with different data\n' + 1148 | 'EXPECTED: ' + prettyPrint(expectation.data) + '\nGOT: ' + data); 1149 | 1150 | if (!expectation.matchHeaders(headers)) 1151 | throw new Error('Expected ' + expectation + ' with different headers\n' + 1152 | 'EXPECTED: ' + prettyPrint(expectation.headers) + '\nGOT: ' + 1153 | prettyPrint(headers)); 1154 | 1155 | expectations.shift(); 1156 | 1157 | if (expectation.response) { 1158 | responses.push(wrapResponse(expectation)); 1159 | return; 1160 | } 1161 | wasExpected = true; 1162 | } 1163 | 1164 | var i = -1, definition; 1165 | while ((definition = definitions[++i])) { 1166 | if (definition.match(method, url, data, headers || {})) { 1167 | if (definition.response) { 1168 | // if $browser specified, we do auto flush all requests 1169 | ($browser ? $browser.defer : responsesPush)(wrapResponse(definition)); 1170 | } else if (definition.passThrough) { 1171 | $delegate(method, url, data, callback, headers, timeout, withCredentials); 1172 | } else throw new Error('No response defined !'); 1173 | return; 1174 | } 1175 | } 1176 | throw wasExpected ? 1177 | new Error('No response defined !') : 1178 | new Error('Unexpected request: ' + method + ' ' + url + '\n' + 1179 | (expectation ? 'Expected ' + expectation : 'No more request expected')); 1180 | } 1181 | 1182 | /** 1183 | * @ngdoc method 1184 | * @name $httpBackend#when 1185 | * @description 1186 | * Creates a new backend definition. 1187 | * 1188 | * @param {string} method HTTP method. 1189 | * @param {string|RegExp} url HTTP url. 1190 | * @param {(string|RegExp|function(string))=} data HTTP request body or function that receives 1191 | * data string and returns true if the data is as expected. 1192 | * @param {(Object|function(Object))=} headers HTTP headers or function that receives http header 1193 | * object and returns true if the headers match the current definition. 1194 | * @returns {requestHandler} Returns an object with `respond` method that controls how a matched 1195 | * request is handled. 1196 | * 1197 | * - respond – 1198 | * `{function([status,] data[, headers, statusText]) 1199 | * | function(function(method, url, data, headers)}` 1200 | * – The respond method takes a set of static data to be returned or a function that can 1201 | * return an array containing response status (number), response data (string), response 1202 | * headers (Object), and the text for the status (string). 1203 | */ 1204 | $httpBackend.when = function(method, url, data, headers) { 1205 | var definition = new MockHttpExpectation(method, url, data, headers), 1206 | chain = { 1207 | respond: function(status, data, headers, statusText) { 1208 | definition.response = createResponse(status, data, headers, statusText); 1209 | } 1210 | }; 1211 | 1212 | if ($browser) { 1213 | chain.passThrough = function() { 1214 | definition.passThrough = true; 1215 | }; 1216 | } 1217 | 1218 | definitions.push(definition); 1219 | return chain; 1220 | }; 1221 | 1222 | /** 1223 | * @ngdoc method 1224 | * @name $httpBackend#whenGET 1225 | * @description 1226 | * Creates a new backend definition for GET requests. For more info see `when()`. 1227 | * 1228 | * @param {string|RegExp} url HTTP url. 1229 | * @param {(Object|function(Object))=} headers HTTP headers. 1230 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1231 | * request is handled. 1232 | */ 1233 | 1234 | /** 1235 | * @ngdoc method 1236 | * @name $httpBackend#whenHEAD 1237 | * @description 1238 | * Creates a new backend definition for HEAD requests. For more info see `when()`. 1239 | * 1240 | * @param {string|RegExp} url HTTP url. 1241 | * @param {(Object|function(Object))=} headers HTTP headers. 1242 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1243 | * request is handled. 1244 | */ 1245 | 1246 | /** 1247 | * @ngdoc method 1248 | * @name $httpBackend#whenDELETE 1249 | * @description 1250 | * Creates a new backend definition for DELETE requests. For more info see `when()`. 1251 | * 1252 | * @param {string|RegExp} url HTTP url. 1253 | * @param {(Object|function(Object))=} headers HTTP headers. 1254 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1255 | * request is handled. 1256 | */ 1257 | 1258 | /** 1259 | * @ngdoc method 1260 | * @name $httpBackend#whenPOST 1261 | * @description 1262 | * Creates a new backend definition for POST requests. For more info see `when()`. 1263 | * 1264 | * @param {string|RegExp} url HTTP url. 1265 | * @param {(string|RegExp|function(string))=} data HTTP request body or function that receives 1266 | * data string and returns true if the data is as expected. 1267 | * @param {(Object|function(Object))=} headers HTTP headers. 1268 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1269 | * request is handled. 1270 | */ 1271 | 1272 | /** 1273 | * @ngdoc method 1274 | * @name $httpBackend#whenPUT 1275 | * @description 1276 | * Creates a new backend definition for PUT requests. For more info see `when()`. 1277 | * 1278 | * @param {string|RegExp} url HTTP url. 1279 | * @param {(string|RegExp|function(string))=} data HTTP request body or function that receives 1280 | * data string and returns true if the data is as expected. 1281 | * @param {(Object|function(Object))=} headers HTTP headers. 1282 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1283 | * request is handled. 1284 | */ 1285 | 1286 | /** 1287 | * @ngdoc method 1288 | * @name $httpBackend#whenJSONP 1289 | * @description 1290 | * Creates a new backend definition for JSONP requests. For more info see `when()`. 1291 | * 1292 | * @param {string|RegExp} url HTTP url. 1293 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1294 | * request is handled. 1295 | */ 1296 | createShortMethods('when'); 1297 | 1298 | 1299 | /** 1300 | * @ngdoc method 1301 | * @name $httpBackend#expect 1302 | * @description 1303 | * Creates a new request expectation. 1304 | * 1305 | * @param {string} method HTTP method. 1306 | * @param {string|RegExp} url HTTP url. 1307 | * @param {(string|RegExp|function(string)|Object)=} data HTTP request body or function that 1308 | * receives data string and returns true if the data is as expected, or Object if request body 1309 | * is in JSON format. 1310 | * @param {(Object|function(Object))=} headers HTTP headers or function that receives http header 1311 | * object and returns true if the headers match the current expectation. 1312 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1313 | * request is handled. 1314 | * 1315 | * - respond – 1316 | * `{function([status,] data[, headers, statusText]) 1317 | * | function(function(method, url, data, headers)}` 1318 | * – The respond method takes a set of static data to be returned or a function that can 1319 | * return an array containing response status (number), response data (string), response 1320 | * headers (Object), and the text for the status (string). 1321 | */ 1322 | $httpBackend.expect = function(method, url, data, headers) { 1323 | var expectation = new MockHttpExpectation(method, url, data, headers); 1324 | expectations.push(expectation); 1325 | return { 1326 | respond: function (status, data, headers, statusText) { 1327 | expectation.response = createResponse(status, data, headers, statusText); 1328 | } 1329 | }; 1330 | }; 1331 | 1332 | 1333 | /** 1334 | * @ngdoc method 1335 | * @name $httpBackend#expectGET 1336 | * @description 1337 | * Creates a new request expectation for GET requests. For more info see `expect()`. 1338 | * 1339 | * @param {string|RegExp} url HTTP url. 1340 | * @param {Object=} headers HTTP headers. 1341 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1342 | * request is handled. See #expect for more info. 1343 | */ 1344 | 1345 | /** 1346 | * @ngdoc method 1347 | * @name $httpBackend#expectHEAD 1348 | * @description 1349 | * Creates a new request expectation for HEAD requests. For more info see `expect()`. 1350 | * 1351 | * @param {string|RegExp} url HTTP url. 1352 | * @param {Object=} headers HTTP headers. 1353 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1354 | * request is handled. 1355 | */ 1356 | 1357 | /** 1358 | * @ngdoc method 1359 | * @name $httpBackend#expectDELETE 1360 | * @description 1361 | * Creates a new request expectation for DELETE requests. For more info see `expect()`. 1362 | * 1363 | * @param {string|RegExp} url HTTP url. 1364 | * @param {Object=} headers HTTP headers. 1365 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1366 | * request is handled. 1367 | */ 1368 | 1369 | /** 1370 | * @ngdoc method 1371 | * @name $httpBackend#expectPOST 1372 | * @description 1373 | * Creates a new request expectation for POST requests. For more info see `expect()`. 1374 | * 1375 | * @param {string|RegExp} url HTTP url. 1376 | * @param {(string|RegExp|function(string)|Object)=} data HTTP request body or function that 1377 | * receives data string and returns true if the data is as expected, or Object if request body 1378 | * is in JSON format. 1379 | * @param {Object=} headers HTTP headers. 1380 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1381 | * request is handled. 1382 | */ 1383 | 1384 | /** 1385 | * @ngdoc method 1386 | * @name $httpBackend#expectPUT 1387 | * @description 1388 | * Creates a new request expectation for PUT requests. For more info see `expect()`. 1389 | * 1390 | * @param {string|RegExp} url HTTP url. 1391 | * @param {(string|RegExp|function(string)|Object)=} data HTTP request body or function that 1392 | * receives data string and returns true if the data is as expected, or Object if request body 1393 | * is in JSON format. 1394 | * @param {Object=} headers HTTP headers. 1395 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1396 | * request is handled. 1397 | */ 1398 | 1399 | /** 1400 | * @ngdoc method 1401 | * @name $httpBackend#expectPATCH 1402 | * @description 1403 | * Creates a new request expectation for PATCH requests. For more info see `expect()`. 1404 | * 1405 | * @param {string|RegExp} url HTTP url. 1406 | * @param {(string|RegExp|function(string)|Object)=} data HTTP request body or function that 1407 | * receives data string and returns true if the data is as expected, or Object if request body 1408 | * is in JSON format. 1409 | * @param {Object=} headers HTTP headers. 1410 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1411 | * request is handled. 1412 | */ 1413 | 1414 | /** 1415 | * @ngdoc method 1416 | * @name $httpBackend#expectJSONP 1417 | * @description 1418 | * Creates a new request expectation for JSONP requests. For more info see `expect()`. 1419 | * 1420 | * @param {string|RegExp} url HTTP url. 1421 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1422 | * request is handled. 1423 | */ 1424 | createShortMethods('expect'); 1425 | 1426 | 1427 | /** 1428 | * @ngdoc method 1429 | * @name $httpBackend#flush 1430 | * @description 1431 | * Flushes all pending requests using the trained responses. 1432 | * 1433 | * @param {number=} count Number of responses to flush (in the order they arrived). If undefined, 1434 | * all pending requests will be flushed. If there are no pending requests when the flush method 1435 | * is called an exception is thrown (as this typically a sign of programming error). 1436 | */ 1437 | $httpBackend.flush = function(count) { 1438 | $rootScope.$digest(); 1439 | if (!responses.length) throw new Error('No pending request to flush !'); 1440 | 1441 | if (angular.isDefined(count)) { 1442 | while (count--) { 1443 | if (!responses.length) throw new Error('No more pending request to flush !'); 1444 | responses.shift()(); 1445 | } 1446 | } else { 1447 | while (responses.length) { 1448 | responses.shift()(); 1449 | } 1450 | } 1451 | $httpBackend.verifyNoOutstandingExpectation(); 1452 | }; 1453 | 1454 | 1455 | /** 1456 | * @ngdoc method 1457 | * @name $httpBackend#verifyNoOutstandingExpectation 1458 | * @description 1459 | * Verifies that all of the requests defined via the `expect` api were made. If any of the 1460 | * requests were not made, verifyNoOutstandingExpectation throws an exception. 1461 | * 1462 | * Typically, you would call this method following each test case that asserts requests using an 1463 | * "afterEach" clause. 1464 | * 1465 | * ```js 1466 | * afterEach($httpBackend.verifyNoOutstandingExpectation); 1467 | * ``` 1468 | */ 1469 | $httpBackend.verifyNoOutstandingExpectation = function() { 1470 | $rootScope.$digest(); 1471 | if (expectations.length) { 1472 | throw new Error('Unsatisfied requests: ' + expectations.join(', ')); 1473 | } 1474 | }; 1475 | 1476 | 1477 | /** 1478 | * @ngdoc method 1479 | * @name $httpBackend#verifyNoOutstandingRequest 1480 | * @description 1481 | * Verifies that there are no outstanding requests that need to be flushed. 1482 | * 1483 | * Typically, you would call this method following each test case that asserts requests using an 1484 | * "afterEach" clause. 1485 | * 1486 | * ```js 1487 | * afterEach($httpBackend.verifyNoOutstandingRequest); 1488 | * ``` 1489 | */ 1490 | $httpBackend.verifyNoOutstandingRequest = function() { 1491 | if (responses.length) { 1492 | throw new Error('Unflushed requests: ' + responses.length); 1493 | } 1494 | }; 1495 | 1496 | 1497 | /** 1498 | * @ngdoc method 1499 | * @name $httpBackend#resetExpectations 1500 | * @description 1501 | * Resets all request expectations, but preserves all backend definitions. Typically, you would 1502 | * call resetExpectations during a multiple-phase test when you want to reuse the same instance of 1503 | * $httpBackend mock. 1504 | */ 1505 | $httpBackend.resetExpectations = function() { 1506 | expectations.length = 0; 1507 | responses.length = 0; 1508 | }; 1509 | 1510 | return $httpBackend; 1511 | 1512 | 1513 | function createShortMethods(prefix) { 1514 | angular.forEach(['GET', 'DELETE', 'JSONP'], function(method) { 1515 | $httpBackend[prefix + method] = function(url, headers) { 1516 | return $httpBackend[prefix](method, url, undefined, headers); 1517 | }; 1518 | }); 1519 | 1520 | angular.forEach(['PUT', 'POST', 'PATCH'], function(method) { 1521 | $httpBackend[prefix + method] = function(url, data, headers) { 1522 | return $httpBackend[prefix](method, url, data, headers); 1523 | }; 1524 | }); 1525 | } 1526 | } 1527 | 1528 | function MockHttpExpectation(method, url, data, headers) { 1529 | 1530 | this.data = data; 1531 | this.headers = headers; 1532 | 1533 | this.match = function(m, u, d, h) { 1534 | if (method != m) return false; 1535 | if (!this.matchUrl(u)) return false; 1536 | if (angular.isDefined(d) && !this.matchData(d)) return false; 1537 | if (angular.isDefined(h) && !this.matchHeaders(h)) return false; 1538 | return true; 1539 | }; 1540 | 1541 | this.matchUrl = function(u) { 1542 | if (!url) return true; 1543 | if (angular.isFunction(url.test)) return url.test(u); 1544 | return url == u; 1545 | }; 1546 | 1547 | this.matchHeaders = function(h) { 1548 | if (angular.isUndefined(headers)) return true; 1549 | if (angular.isFunction(headers)) return headers(h); 1550 | return angular.equals(headers, h); 1551 | }; 1552 | 1553 | this.matchData = function(d) { 1554 | if (angular.isUndefined(data)) return true; 1555 | if (data && angular.isFunction(data.test)) return data.test(d); 1556 | if (data && angular.isFunction(data)) return data(d); 1557 | if (data && !angular.isString(data)) return angular.equals(data, angular.fromJson(d)); 1558 | return data == d; 1559 | }; 1560 | 1561 | this.toString = function() { 1562 | return method + ' ' + url; 1563 | }; 1564 | } 1565 | 1566 | function createMockXhr() { 1567 | return new MockXhr(); 1568 | } 1569 | 1570 | function MockXhr() { 1571 | 1572 | // hack for testing $http, $httpBackend 1573 | MockXhr.$$lastInstance = this; 1574 | 1575 | this.open = function(method, url, async) { 1576 | this.$$method = method; 1577 | this.$$url = url; 1578 | this.$$async = async; 1579 | this.$$reqHeaders = {}; 1580 | this.$$respHeaders = {}; 1581 | }; 1582 | 1583 | this.send = function(data) { 1584 | this.$$data = data; 1585 | }; 1586 | 1587 | this.setRequestHeader = function(key, value) { 1588 | this.$$reqHeaders[key] = value; 1589 | }; 1590 | 1591 | this.getResponseHeader = function(name) { 1592 | // the lookup must be case insensitive, 1593 | // that's why we try two quick lookups first and full scan last 1594 | var header = this.$$respHeaders[name]; 1595 | if (header) return header; 1596 | 1597 | name = angular.lowercase(name); 1598 | header = this.$$respHeaders[name]; 1599 | if (header) return header; 1600 | 1601 | header = undefined; 1602 | angular.forEach(this.$$respHeaders, function(headerVal, headerName) { 1603 | if (!header && angular.lowercase(headerName) == name) header = headerVal; 1604 | }); 1605 | return header; 1606 | }; 1607 | 1608 | this.getAllResponseHeaders = function() { 1609 | var lines = []; 1610 | 1611 | angular.forEach(this.$$respHeaders, function(value, key) { 1612 | lines.push(key + ': ' + value); 1613 | }); 1614 | return lines.join('\n'); 1615 | }; 1616 | 1617 | this.abort = angular.noop; 1618 | } 1619 | 1620 | 1621 | /** 1622 | * @ngdoc service 1623 | * @name $timeout 1624 | * @description 1625 | * 1626 | * This service is just a simple decorator for {@link ng.$timeout $timeout} service 1627 | * that adds a "flush" and "verifyNoPendingTasks" methods. 1628 | */ 1629 | 1630 | angular.mock.$TimeoutDecorator = function($delegate, $browser) { 1631 | 1632 | /** 1633 | * @ngdoc method 1634 | * @name $timeout#flush 1635 | * @description 1636 | * 1637 | * Flushes the queue of pending tasks. 1638 | * 1639 | * @param {number=} delay maximum timeout amount to flush up until 1640 | */ 1641 | $delegate.flush = function(delay) { 1642 | $browser.defer.flush(delay); 1643 | }; 1644 | 1645 | /** 1646 | * @ngdoc method 1647 | * @name $timeout#verifyNoPendingTasks 1648 | * @description 1649 | * 1650 | * Verifies that there are no pending tasks that need to be flushed. 1651 | */ 1652 | $delegate.verifyNoPendingTasks = function() { 1653 | if ($browser.deferredFns.length) { 1654 | throw new Error('Deferred tasks to flush (' + $browser.deferredFns.length + '): ' + 1655 | formatPendingTasksAsString($browser.deferredFns)); 1656 | } 1657 | }; 1658 | 1659 | function formatPendingTasksAsString(tasks) { 1660 | var result = []; 1661 | angular.forEach(tasks, function(task) { 1662 | result.push('{id: ' + task.id + ', ' + 'time: ' + task.time + '}'); 1663 | }); 1664 | 1665 | return result.join(', '); 1666 | } 1667 | 1668 | return $delegate; 1669 | }; 1670 | 1671 | angular.mock.$RAFDecorator = function($delegate) { 1672 | var queue = []; 1673 | var rafFn = function(fn) { 1674 | var index = queue.length; 1675 | queue.push(fn); 1676 | return function() { 1677 | queue.splice(index, 1); 1678 | }; 1679 | }; 1680 | 1681 | rafFn.supported = $delegate.supported; 1682 | 1683 | rafFn.flush = function() { 1684 | if(queue.length === 0) { 1685 | throw new Error('No rAF callbacks present'); 1686 | } 1687 | 1688 | var length = queue.length; 1689 | for(var i=0;i'); 1719 | }; 1720 | }; 1721 | 1722 | /** 1723 | * @ngdoc module 1724 | * @name ngMock 1725 | * @packageName angular-mocks 1726 | * @description 1727 | * 1728 | * # ngMock 1729 | * 1730 | * The `ngMock` module provides support to inject and mock Angular services into unit tests. 1731 | * In addition, ngMock also extends various core ng services such that they can be 1732 | * inspected and controlled in a synchronous manner within test code. 1733 | * 1734 | * 1735 | *
1736 | * 1737 | */ 1738 | angular.module('ngMock', ['ng']).provider({ 1739 | $browser: angular.mock.$BrowserProvider, 1740 | $exceptionHandler: angular.mock.$ExceptionHandlerProvider, 1741 | $log: angular.mock.$LogProvider, 1742 | $interval: angular.mock.$IntervalProvider, 1743 | $httpBackend: angular.mock.$HttpBackendProvider, 1744 | $rootElement: angular.mock.$RootElementProvider 1745 | }).config(['$provide', function($provide) { 1746 | $provide.decorator('$timeout', angular.mock.$TimeoutDecorator); 1747 | $provide.decorator('$$rAF', angular.mock.$RAFDecorator); 1748 | $provide.decorator('$$asyncCallback', angular.mock.$AsyncCallbackDecorator); 1749 | }]); 1750 | 1751 | /** 1752 | * @ngdoc module 1753 | * @name ngMockE2E 1754 | * @module ngMockE2E 1755 | * @packageName angular-mocks 1756 | * @description 1757 | * 1758 | * The `ngMockE2E` is an angular module which contains mocks suitable for end-to-end testing. 1759 | * Currently there is only one mock present in this module - 1760 | * the {@link ngMockE2E.$httpBackend e2e $httpBackend} mock. 1761 | */ 1762 | angular.module('ngMockE2E', ['ng']).config(['$provide', function($provide) { 1763 | $provide.decorator('$httpBackend', angular.mock.e2e.$httpBackendDecorator); 1764 | }]); 1765 | 1766 | /** 1767 | * @ngdoc service 1768 | * @name $httpBackend 1769 | * @module ngMockE2E 1770 | * @description 1771 | * Fake HTTP backend implementation suitable for end-to-end testing or backend-less development of 1772 | * applications that use the {@link ng.$http $http service}. 1773 | * 1774 | * *Note*: For fake http backend implementation suitable for unit testing please see 1775 | * {@link ngMock.$httpBackend unit-testing $httpBackend mock}. 1776 | * 1777 | * This implementation can be used to respond with static or dynamic responses via the `when` api 1778 | * and its shortcuts (`whenGET`, `whenPOST`, etc) and optionally pass through requests to the 1779 | * real $httpBackend for specific requests (e.g. to interact with certain remote apis or to fetch 1780 | * templates from a webserver). 1781 | * 1782 | * As opposed to unit-testing, in an end-to-end testing scenario or in scenario when an application 1783 | * is being developed with the real backend api replaced with a mock, it is often desirable for 1784 | * certain category of requests to bypass the mock and issue a real http request (e.g. to fetch 1785 | * templates or static files from the webserver). To configure the backend with this behavior 1786 | * use the `passThrough` request handler of `when` instead of `respond`. 1787 | * 1788 | * Additionally, we don't want to manually have to flush mocked out requests like we do during unit 1789 | * testing. For this reason the e2e $httpBackend automatically flushes mocked out requests 1790 | * automatically, closely simulating the behavior of the XMLHttpRequest object. 1791 | * 1792 | * To setup the application to run with this http backend, you have to create a module that depends 1793 | * on the `ngMockE2E` and your application modules and defines the fake backend: 1794 | * 1795 | * ```js 1796 | * myAppDev = angular.module('myAppDev', ['myApp', 'ngMockE2E']); 1797 | * myAppDev.run(function($httpBackend) { 1798 | * phones = [{name: 'phone1'}, {name: 'phone2'}]; 1799 | * 1800 | * // returns the current list of phones 1801 | * $httpBackend.whenGET('/phones').respond(phones); 1802 | * 1803 | * // adds a new phone to the phones array 1804 | * $httpBackend.whenPOST('/phones').respond(function(method, url, data) { 1805 | * var phone = angular.fromJson(data); 1806 | * phones.push(phone); 1807 | * return [200, phone, {}]; 1808 | * }); 1809 | * $httpBackend.whenGET(/^\/templates\//).passThrough(); 1810 | * //... 1811 | * }); 1812 | * ``` 1813 | * 1814 | * Afterwards, bootstrap your app with this new module. 1815 | */ 1816 | 1817 | /** 1818 | * @ngdoc method 1819 | * @name $httpBackend#when 1820 | * @module ngMockE2E 1821 | * @description 1822 | * Creates a new backend definition. 1823 | * 1824 | * @param {string} method HTTP method. 1825 | * @param {string|RegExp} url HTTP url. 1826 | * @param {(string|RegExp)=} data HTTP request body. 1827 | * @param {(Object|function(Object))=} headers HTTP headers or function that receives http header 1828 | * object and returns true if the headers match the current definition. 1829 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1830 | * control how a matched request is handled. 1831 | * 1832 | * - respond – 1833 | * `{function([status,] data[, headers, statusText]) 1834 | * | function(function(method, url, data, headers)}` 1835 | * – The respond method takes a set of static data to be returned or a function that can return 1836 | * an array containing response status (number), response data (string), response headers 1837 | * (Object), and the text for the status (string). 1838 | * - passThrough – `{function()}` – Any request matching a backend definition with 1839 | * `passThrough` handler will be passed through to the real backend (an XHR request will be made 1840 | * to the server.) 1841 | */ 1842 | 1843 | /** 1844 | * @ngdoc method 1845 | * @name $httpBackend#whenGET 1846 | * @module ngMockE2E 1847 | * @description 1848 | * Creates a new backend definition for GET requests. For more info see `when()`. 1849 | * 1850 | * @param {string|RegExp} url HTTP url. 1851 | * @param {(Object|function(Object))=} headers HTTP headers. 1852 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1853 | * control how a matched request is handled. 1854 | */ 1855 | 1856 | /** 1857 | * @ngdoc method 1858 | * @name $httpBackend#whenHEAD 1859 | * @module ngMockE2E 1860 | * @description 1861 | * Creates a new backend definition for HEAD requests. For more info see `when()`. 1862 | * 1863 | * @param {string|RegExp} url HTTP url. 1864 | * @param {(Object|function(Object))=} headers HTTP headers. 1865 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1866 | * control how a matched request is handled. 1867 | */ 1868 | 1869 | /** 1870 | * @ngdoc method 1871 | * @name $httpBackend#whenDELETE 1872 | * @module ngMockE2E 1873 | * @description 1874 | * Creates a new backend definition for DELETE requests. For more info see `when()`. 1875 | * 1876 | * @param {string|RegExp} url HTTP url. 1877 | * @param {(Object|function(Object))=} headers HTTP headers. 1878 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1879 | * control how a matched request is handled. 1880 | */ 1881 | 1882 | /** 1883 | * @ngdoc method 1884 | * @name $httpBackend#whenPOST 1885 | * @module ngMockE2E 1886 | * @description 1887 | * Creates a new backend definition for POST requests. For more info see `when()`. 1888 | * 1889 | * @param {string|RegExp} url HTTP url. 1890 | * @param {(string|RegExp)=} data HTTP request body. 1891 | * @param {(Object|function(Object))=} headers HTTP headers. 1892 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1893 | * control how a matched request is handled. 1894 | */ 1895 | 1896 | /** 1897 | * @ngdoc method 1898 | * @name $httpBackend#whenPUT 1899 | * @module ngMockE2E 1900 | * @description 1901 | * Creates a new backend definition for PUT requests. For more info see `when()`. 1902 | * 1903 | * @param {string|RegExp} url HTTP url. 1904 | * @param {(string|RegExp)=} data HTTP request body. 1905 | * @param {(Object|function(Object))=} headers HTTP headers. 1906 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1907 | * control how a matched request is handled. 1908 | */ 1909 | 1910 | /** 1911 | * @ngdoc method 1912 | * @name $httpBackend#whenPATCH 1913 | * @module ngMockE2E 1914 | * @description 1915 | * Creates a new backend definition for PATCH requests. For more info see `when()`. 1916 | * 1917 | * @param {string|RegExp} url HTTP url. 1918 | * @param {(string|RegExp)=} data HTTP request body. 1919 | * @param {(Object|function(Object))=} headers HTTP headers. 1920 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1921 | * control how a matched request is handled. 1922 | */ 1923 | 1924 | /** 1925 | * @ngdoc method 1926 | * @name $httpBackend#whenJSONP 1927 | * @module ngMockE2E 1928 | * @description 1929 | * Creates a new backend definition for JSONP requests. For more info see `when()`. 1930 | * 1931 | * @param {string|RegExp} url HTTP url. 1932 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1933 | * control how a matched request is handled. 1934 | */ 1935 | angular.mock.e2e = {}; 1936 | angular.mock.e2e.$httpBackendDecorator = 1937 | ['$rootScope', '$delegate', '$browser', createHttpBackendMock]; 1938 | 1939 | 1940 | angular.mock.clearDataCache = function() { 1941 | var key, 1942 | cache = angular.element.cache; 1943 | 1944 | for(key in cache) { 1945 | if (Object.prototype.hasOwnProperty.call(cache,key)) { 1946 | var handle = cache[key].handle; 1947 | 1948 | handle && angular.element(handle.elem).off(); 1949 | delete cache[key]; 1950 | } 1951 | } 1952 | }; 1953 | 1954 | 1955 | if(window.jasmine || window.mocha) { 1956 | 1957 | var currentSpec = null, 1958 | isSpecRunning = function() { 1959 | return !!currentSpec; 1960 | }; 1961 | 1962 | 1963 | (window.beforeEach || window.setup)(function() { 1964 | currentSpec = this; 1965 | }); 1966 | 1967 | (window.afterEach || window.teardown)(function() { 1968 | var injector = currentSpec.$injector; 1969 | 1970 | angular.forEach(currentSpec.$modules, function(module) { 1971 | if (module && module.$$hashKey) { 1972 | module.$$hashKey = undefined; 1973 | } 1974 | }); 1975 | 1976 | currentSpec.$injector = null; 1977 | currentSpec.$modules = null; 1978 | currentSpec = null; 1979 | 1980 | if (injector) { 1981 | injector.get('$rootElement').off(); 1982 | injector.get('$browser').pollFns.length = 0; 1983 | } 1984 | 1985 | angular.mock.clearDataCache(); 1986 | 1987 | // clean up jquery's fragment cache 1988 | angular.forEach(angular.element.fragments, function(val, key) { 1989 | delete angular.element.fragments[key]; 1990 | }); 1991 | 1992 | MockXhr.$$lastInstance = null; 1993 | 1994 | angular.forEach(angular.callbacks, function(val, key) { 1995 | delete angular.callbacks[key]; 1996 | }); 1997 | angular.callbacks.counter = 0; 1998 | }); 1999 | 2000 | /** 2001 | * @ngdoc function 2002 | * @name angular.mock.module 2003 | * @description 2004 | * 2005 | * *NOTE*: This function is also published on window for easy access.
2006 | * 2007 | * This function registers a module configuration code. It collects the configuration information 2008 | * which will be used when the injector is created by {@link angular.mock.inject inject}. 2009 | * 2010 | * See {@link angular.mock.inject inject} for usage example 2011 | * 2012 | * @param {...(string|Function|Object)} fns any number of modules which are represented as string 2013 | * aliases or as anonymous module initialization functions. The modules are used to 2014 | * configure the injector. The 'ng' and 'ngMock' modules are automatically loaded. If an 2015 | * object literal is passed they will be registered as values in the module, the key being 2016 | * the module name and the value being what is returned. 2017 | */ 2018 | window.module = angular.mock.module = function() { 2019 | var moduleFns = Array.prototype.slice.call(arguments, 0); 2020 | return isSpecRunning() ? workFn() : workFn; 2021 | ///////////////////// 2022 | function workFn() { 2023 | if (currentSpec.$injector) { 2024 | throw new Error('Injector already created, can not register a module!'); 2025 | } else { 2026 | var modules = currentSpec.$modules || (currentSpec.$modules = []); 2027 | angular.forEach(moduleFns, function(module) { 2028 | if (angular.isObject(module) && !angular.isArray(module)) { 2029 | modules.push(function($provide) { 2030 | angular.forEach(module, function(value, key) { 2031 | $provide.value(key, value); 2032 | }); 2033 | }); 2034 | } else { 2035 | modules.push(module); 2036 | } 2037 | }); 2038 | } 2039 | } 2040 | }; 2041 | 2042 | /** 2043 | * @ngdoc function 2044 | * @name angular.mock.inject 2045 | * @description 2046 | * 2047 | * *NOTE*: This function is also published on window for easy access.
2048 | * 2049 | * The inject function wraps a function into an injectable function. The inject() creates new 2050 | * instance of {@link auto.$injector $injector} per test, which is then used for 2051 | * resolving references. 2052 | * 2053 | * 2054 | * ## Resolving References (Underscore Wrapping) 2055 | * Often, we would like to inject a reference once, in a `beforeEach()` block and reuse this 2056 | * in multiple `it()` clauses. To be able to do this we must assign the reference to a variable 2057 | * that is declared in the scope of the `describe()` block. Since we would, most likely, want 2058 | * the variable to have the same name of the reference we have a problem, since the parameter 2059 | * to the `inject()` function would hide the outer variable. 2060 | * 2061 | * To help with this, the injected parameters can, optionally, be enclosed with underscores. 2062 | * These are ignored by the injector when the reference name is resolved. 2063 | * 2064 | * For example, the parameter `_myService_` would be resolved as the reference `myService`. 2065 | * Since it is available in the function body as _myService_, we can then assign it to a variable 2066 | * defined in an outer scope. 2067 | * 2068 | * ``` 2069 | * // Defined out reference variable outside 2070 | * var myService; 2071 | * 2072 | * // Wrap the parameter in underscores 2073 | * beforeEach( inject( function(_myService_){ 2074 | * myService = _myService_; 2075 | * })); 2076 | * 2077 | * // Use myService in a series of tests. 2078 | * it('makes use of myService', function() { 2079 | * myService.doStuff(); 2080 | * }); 2081 | * 2082 | * ``` 2083 | * 2084 | * See also {@link angular.mock.module angular.mock.module} 2085 | * 2086 | * ## Example 2087 | * Example of what a typical jasmine tests looks like with the inject method. 2088 | * ```js 2089 | * 2090 | * angular.module('myApplicationModule', []) 2091 | * .value('mode', 'app') 2092 | * .value('version', 'v1.0.1'); 2093 | * 2094 | * 2095 | * describe('MyApp', function() { 2096 | * 2097 | * // You need to load modules that you want to test, 2098 | * // it loads only the "ng" module by default. 2099 | * beforeEach(module('myApplicationModule')); 2100 | * 2101 | * 2102 | * // inject() is used to inject arguments of all given functions 2103 | * it('should provide a version', inject(function(mode, version) { 2104 | * expect(version).toEqual('v1.0.1'); 2105 | * expect(mode).toEqual('app'); 2106 | * })); 2107 | * 2108 | * 2109 | * // The inject and module method can also be used inside of the it or beforeEach 2110 | * it('should override a version and test the new version is injected', function() { 2111 | * // module() takes functions or strings (module aliases) 2112 | * module(function($provide) { 2113 | * $provide.value('version', 'overridden'); // override version here 2114 | * }); 2115 | * 2116 | * inject(function(version) { 2117 | * expect(version).toEqual('overridden'); 2118 | * }); 2119 | * }); 2120 | * }); 2121 | * 2122 | * ``` 2123 | * 2124 | * @param {...Function} fns any number of functions which will be injected using the injector. 2125 | */ 2126 | 2127 | 2128 | 2129 | var ErrorAddingDeclarationLocationStack = function(e, errorForStack) { 2130 | this.message = e.message; 2131 | this.name = e.name; 2132 | if (e.line) this.line = e.line; 2133 | if (e.sourceId) this.sourceId = e.sourceId; 2134 | if (e.stack && errorForStack) 2135 | this.stack = e.stack + '\n' + errorForStack.stack; 2136 | if (e.stackArray) this.stackArray = e.stackArray; 2137 | }; 2138 | ErrorAddingDeclarationLocationStack.prototype.toString = Error.prototype.toString; 2139 | 2140 | window.inject = angular.mock.inject = function() { 2141 | var blockFns = Array.prototype.slice.call(arguments, 0); 2142 | var errorForStack = new Error('Declaration Location'); 2143 | return isSpecRunning() ? workFn.call(currentSpec) : workFn; 2144 | ///////////////////// 2145 | function workFn() { 2146 | var modules = currentSpec.$modules || []; 2147 | 2148 | modules.unshift('ngMock'); 2149 | modules.unshift('ng'); 2150 | var injector = currentSpec.$injector; 2151 | if (!injector) { 2152 | injector = currentSpec.$injector = angular.injector(modules); 2153 | } 2154 | for(var i = 0, ii = blockFns.length; i < ii; i++) { 2155 | try { 2156 | /* jshint -W040 *//* Jasmine explicitly provides a `this` object when calling functions */ 2157 | injector.invoke(blockFns[i] || angular.noop, this); 2158 | /* jshint +W040 */ 2159 | } catch (e) { 2160 | if (e.stack && errorForStack) { 2161 | throw new ErrorAddingDeclarationLocationStack(e, errorForStack); 2162 | } 2163 | throw e; 2164 | } finally { 2165 | errorForStack = null; 2166 | } 2167 | } 2168 | } 2169 | }; 2170 | } 2171 | 2172 | 2173 | })(window, window.angular); 2174 | -------------------------------------------------------------------------------- /test/lib/browser_trigger.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var msie = parseInt((/msie (\d+)/.exec(navigator.userAgent.toLowerCase()) || [])[1], 10); 3 | 4 | function indexOf(array, obj) { 5 | if (array.indexOf) return array.indexOf(obj); 6 | 7 | for ( var i = 0; i < array.length; i++) { 8 | if (obj === array[i]) return i; 9 | } 10 | return -1; 11 | } 12 | 13 | 14 | 15 | /** 16 | * Triggers a browser event. Attempts to choose the right event if one is 17 | * not specified. 18 | * 19 | * @param {Object} element Either a wrapped jQuery/jqLite node or a DOMElement 20 | * @param {string} eventType Optional event type 21 | * @param {Object=} eventData An optional object which contains additional event data (such as x,y 22 | * coordinates, keys, etc...) that are passed into the event when triggered 23 | */ 24 | window.browserTrigger = function browserTrigger(element, eventType, eventData) { 25 | if (element && !element.nodeName) element = element[0]; 26 | if (!element) return; 27 | 28 | eventData = eventData || {}; 29 | var keys = eventData.keys; 30 | var x = eventData.x; 31 | var y = eventData.y; 32 | 33 | var inputType = (element.type) ? element.type.toLowerCase() : null, 34 | nodeName = element.nodeName.toLowerCase(); 35 | 36 | if (!eventType) { 37 | eventType = { 38 | 'text': 'change', 39 | 'textarea': 'change', 40 | 'hidden': 'change', 41 | 'password': 'change', 42 | 'button': 'click', 43 | 'submit': 'click', 44 | 'reset': 'click', 45 | 'image': 'click', 46 | 'checkbox': 'click', 47 | 'radio': 'click', 48 | 'select-one': 'change', 49 | 'select-multiple': 'change', 50 | '_default_': 'click' 51 | }[inputType || '_default_']; 52 | } 53 | 54 | if (nodeName == 'option') { 55 | element.parentNode.value = element.value; 56 | element = element.parentNode; 57 | eventType = 'change'; 58 | } 59 | 60 | keys = keys || []; 61 | function pressed(key) { 62 | return indexOf(keys, key) !== -1; 63 | } 64 | 65 | if (msie < 9) { 66 | if (inputType == 'radio' || inputType == 'checkbox') { 67 | element.checked = !element.checked; 68 | } 69 | 70 | // WTF!!! Error: Unspecified error. 71 | // Don't know why, but some elements when detached seem to be in inconsistent state and 72 | // calling .fireEvent() on them will result in very unhelpful error (Error: Unspecified error) 73 | // forcing the browser to compute the element position (by reading its CSS) 74 | // puts the element in consistent state. 75 | element.style.posLeft; 76 | 77 | // TODO(vojta): create event objects with pressed keys to get it working on IE<9 78 | var ret = element.fireEvent('on' + eventType); 79 | if (inputType == 'submit') { 80 | while(element) { 81 | if (element.nodeName.toLowerCase() == 'form') { 82 | element.fireEvent('onsubmit'); 83 | break; 84 | } 85 | element = element.parentNode; 86 | } 87 | } 88 | return ret; 89 | } else { 90 | var evnt; 91 | if(/transitionend/.test(eventType)) { 92 | if(window.WebKitTransitionEvent) { 93 | evnt = new WebKitTransitionEvent(eventType, eventData); 94 | evnt.initEvent(eventType, false, true); 95 | } 96 | else { 97 | try { 98 | evnt = new TransitionEvent(eventType, eventData); 99 | } 100 | catch(e) { 101 | evnt = document.createEvent('TransitionEvent'); 102 | evnt.initTransitionEvent(eventType, null, null, null, eventData.elapsedTime || 0); 103 | } 104 | } 105 | } 106 | else if(/animationend/.test(eventType)) { 107 | if(window.WebKitAnimationEvent) { 108 | evnt = new WebKitAnimationEvent(eventType, eventData); 109 | evnt.initEvent(eventType, false, true); 110 | } 111 | else { 112 | try { 113 | evnt = new AnimationEvent(eventType, eventData); 114 | } 115 | catch(e) { 116 | evnt = document.createEvent('AnimationEvent'); 117 | evnt.initAnimationEvent(eventType, null, null, null, eventData.elapsedTime || 0); 118 | } 119 | } 120 | } 121 | else { 122 | evnt = document.createEvent('MouseEvents'); 123 | x = x || 0; 124 | y = y || 0; 125 | evnt.initMouseEvent(eventType, true, true, window, 0, x, y, x, y, pressed('ctrl'), 126 | pressed('alt'), pressed('shift'), pressed('meta'), 0, element); 127 | } 128 | 129 | /* we're unable to change the timeStamp value directly so this 130 | * is only here to allow for testing where the timeStamp value is 131 | * read */ 132 | evnt.$manualTimeStamp = eventData.timeStamp; 133 | 134 | if(!evnt) return; 135 | 136 | var originalPreventDefault = evnt.preventDefault, 137 | appWindow = element.ownerDocument.defaultView, 138 | fakeProcessDefault = true, 139 | finalProcessDefault, 140 | angular = appWindow.angular || {}; 141 | 142 | // igor: temporary fix for https://bugzilla.mozilla.org/show_bug.cgi?id=684208 143 | angular['ff-684208-preventDefault'] = false; 144 | evnt.preventDefault = function() { 145 | fakeProcessDefault = false; 146 | return originalPreventDefault.apply(evnt, arguments); 147 | }; 148 | 149 | element.dispatchEvent(evnt); 150 | finalProcessDefault = !(angular['ff-684208-preventDefault'] || !fakeProcessDefault); 151 | 152 | delete angular['ff-684208-preventDefault']; 153 | 154 | return finalProcessDefault; 155 | } 156 | }; 157 | }()); 158 | -------------------------------------------------------------------------------- /test/pickadate-date_helper.spec.js: -------------------------------------------------------------------------------- 1 | /* jshint expr: true */ 2 | 3 | describe('pickadate.dateHelper', function () { 4 | 'use strict'; 5 | var dateHelper = null, 6 | format = 'yyyy-MM-dd'; 7 | 8 | beforeEach(module("pickadate")); 9 | 10 | beforeEach(inject(function (_pickadateDateHelper_) { 11 | dateHelper = _pickadateDateHelper_; 12 | })); 13 | 14 | 15 | describe('parseDate', function() { 16 | 17 | [ 18 | ['2014-02-04', null], 19 | ['2014-02-04', 'yyyy-MM-dd'], 20 | ['2014-04-02', 'yyyy-dd-MM'], 21 | ['2014/02/04', 'yyyy/MM/dd'], 22 | ['2014/04/02', 'yyyy/dd/MM'], 23 | ['04/02/2014', 'dd/MM/yyyy'], 24 | ['04-02-2014', 'dd-MM-yyyy'] 25 | ].forEach(function(format) { 26 | 27 | it("parses the string in " + format[1] + " format and return a date object", function() { 28 | var dateString = format[0], 29 | date = dateHelper(format[1]).parseDate(dateString); 30 | 31 | expect(date.getDate()).to.equal(4); 32 | expect(date.getMonth()).to.equal(1); 33 | expect(date.getFullYear()).to.equal(2014); 34 | expect(date.getHours()).to.equal(3); 35 | }); 36 | 37 | }); 38 | 39 | it("returns undefined if a falsey object is passed", function() { 40 | expect(dateHelper().parseDate(null)).to.be.undefined; 41 | expect(dateHelper().parseDate(undefined)).to.be.undefined; 42 | }); 43 | 44 | it("returns undefined if an invalid date is passed", function() { 45 | expect(dateHelper().parseDate('2014-02-')).to.be.undefined; 46 | }); 47 | 48 | it("returns a new date if a date object is passed", function() { 49 | var date = new Date(); 50 | 51 | expect(dateHelper().parseDate(date).getTime()).to.equal(date.getTime()); 52 | }); 53 | 54 | }); 55 | 56 | describe('buildDayNames', function() { 57 | 58 | it('builds the days', function() { 59 | var expectedResult = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 60 | expect(dateHelper().buildDayNames()).to.deep.equal(expectedResult); 61 | }); 62 | 63 | it('rotates the days', function() { 64 | var expectedResult = ['Wed', 'Thu', 'Fri', 'Sat', 'Sun', 'Mon', 'Tue']; 65 | expect(dateHelper(format, { weekStartsOn: 3 }).buildDayNames()).to.deep.equal(expectedResult); 66 | 67 | expectedResult = ['Fri', 'Sat', 'Sun', 'Mon', 'Tue', 'Wed', 'Thu']; 68 | expect(dateHelper(format, { weekStartsOn: 5 }).buildDayNames()).to.deep.equal(expectedResult); 69 | }); 70 | 71 | }); 72 | 73 | describe('buildDates', function() { 74 | 75 | function d(date) { return dateHelper().parseDate(date); } 76 | 77 | it('returns the correct dates', function() { 78 | var dates = dateHelper().buildDates(2015, 0, { weekStartsOn: 0 }); 79 | 80 | expect(dates[0].date).to.deep.equal(d('2014-12-28')); 81 | expect(dates[3].date).to.deep.equal(d('2014-12-31')); 82 | expect(dates[4].date).to.deep.equal(d('2015-01-01')); 83 | expect(dates[34].date).to.deep.equal(d('2015-01-31')); 84 | expect(dates[35].date).to.deep.equal(d('2015-02-01')); 85 | expect(dates.slice(-1)[0].date).to.deep.equal(d('2015-02-07')); 86 | }); 87 | 88 | it('has 6 rows of dates by default', function() { 89 | expect(dateHelper(format, { weekStartsOn: 0 }).buildDates(2015, 0)).to.have.length(6 * 7); 90 | }); 91 | 92 | it('should not add empty rows when told not to', function() { 93 | expect(dateHelper(format, { weekStartsOn: 0, noExtraRows: true }).buildDates(2015, 0)).to.have.length(5 * 7); 94 | }); 95 | 96 | it('adds 2 extra rows when required', function() { 97 | expect(dateHelper(format, { noExtraRows: false }).buildDates(2015, 1)).to.have.length(6 * 7); 98 | expect(dateHelper(format, { noExtraRows: true }).buildDates(2015, 1)).to.have.length(4 * 7); 99 | expect(dateHelper(format, { weekStartsOn: 1, noExtraRows: true }).buildDates(2015, 1)).to.have.length(5 * 7); 100 | }); 101 | 102 | it('works when the week starts on monday', function() { 103 | var dates = dateHelper(format, { weekStartsOn: 1 }).buildDates(2015, 0); 104 | 105 | expect(dates[0].date).to.deep.equal(d('2014-12-29')); 106 | expect(dates[3].date).to.deep.equal(d('2015-01-01')); 107 | expect(dates[33].date).to.deep.equal(d('2015-01-31')); 108 | expect(dates.slice(-1)[0].date).to.deep.equal(d('2015-02-08')); 109 | }); 110 | 111 | it('works when the week starts on saturday', function() { 112 | var dates = dateHelper(format, { weekStartsOn: 6 }).buildDates(2015, 0); 113 | 114 | expect(dates[0].date).to.deep.equal(d('2014-12-27')); 115 | expect(dates[5].date).to.deep.equal(d('2015-01-01')); 116 | expect(dates[35].date).to.deep.equal(d('2015-01-31')); 117 | expect(dates.slice(-1)[0].date).to.deep.equal(d('2015-02-06')); 118 | }); 119 | 120 | }); 121 | 122 | }); 123 | --------------------------------------------------------------------------------