├── .gitignore ├── .travis.yml ├── bower.json ├── package.json ├── LICENSE ├── karma.conf.js ├── gulpfile.js ├── src ├── angular-pickadate.scss └── angular-pickadate.js ├── dist ├── angular-pickadate.css ├── angular-pickadate.min.js └── angular-pickadate.js ├── test ├── pickadate-utils.spec.js ├── lib │ ├── browser_trigger.js │ └── angular-mocks-1.2.21.js └── angular-pickadate.spec.js ├── README.md └── .jshintrc /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '0.10' 4 | 5 | script: 6 | - npm test 7 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-pickadate", 3 | "version": "1.0.0", 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 | "src/angular-pickadate.js", 18 | "src/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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-pickadate", 3 | "version": "1.0.0", 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": "^1.1.1", 26 | "gulp": "^3.8.7", 27 | "gulp-jshint": "^1.8.4", 28 | "gulp-rename": "^1.2.0", 29 | "gulp-sass": "^1.2.4", 30 | "gulp-uglify": "^1.0.2", 31 | "jquery": "^2.1.1", 32 | "karma": "^0.12.19", 33 | "karma-chai-plugins": "^0.2.3", 34 | "karma-mocha": "^0.1.7", 35 | "karma-phantomjs-launcher": "^0.1.4", 36 | "lodash": "^2.4.1" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/pickadate-utils.spec.js: -------------------------------------------------------------------------------- 1 | /* jshint expr: true */ 2 | 3 | describe('pickadateUtils', function () { 4 | 'use strict'; 5 | var utils = null; 6 | 7 | beforeEach(module("pickadate")); 8 | 9 | beforeEach(inject(function (_pickadateUtils_) { 10 | utils = _pickadateUtils_; 11 | })); 12 | 13 | 14 | describe('parseDate', function() { 15 | 16 | [ 17 | ['2014-02-04', null], 18 | ['2014-02-04', 'yyyy-MM-dd'], 19 | ['2014-04-02', 'yyyy-dd-MM'], 20 | ['2014/02/04', 'yyyy/MM/dd'], 21 | ['2014/04/02', 'yyyy/dd/MM'], 22 | ['04/02/2014', 'dd/MM/yyyy'], 23 | ['04-02-2014', 'dd-MM-yyyy'] 24 | ].forEach(function(format) { 25 | 26 | it("parses the string in " + format[1] + " format and return a date object", function() { 27 | var dateString = format[0], 28 | date = utils.parseDate(dateString, format[1]); 29 | 30 | expect(date.getDate()).to.equal(4); 31 | expect(date.getMonth()).to.equal(1); 32 | expect(date.getFullYear()).to.equal(2014); 33 | expect(date.getHours()).to.equal(3); 34 | }); 35 | 36 | }); 37 | 38 | it("returns undefined if a falsey object is passed", function() { 39 | expect(utils.parseDate(null)).to.be.undefined; 40 | expect(utils.parseDate(undefined)).to.be.undefined; 41 | }); 42 | 43 | it("returns undefined if an invalid date is passed", function() { 44 | expect(utils.parseDate('2014-02-')).to.be.undefined; 45 | }); 46 | 47 | it("returns a new date if a date object is passed", function() { 48 | var date = new Date(); 49 | 50 | expect(utils.parseDate(date).getTime()).to.equal(date.getTime()); 51 | }); 52 | 53 | }); 54 | 55 | describe('buildDayNames', function() { 56 | 57 | it('builds the days', function() { 58 | var expectedResult = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 59 | expect(utils.buildDayNames()).to.deep.equal(expectedResult); 60 | }); 61 | 62 | it('rotates the days', function() { 63 | var expectedResult = ['Wed', 'Thu', 'Fri', 'Sat', 'Sun', 'Mon', 'Tue']; 64 | expect(utils.buildDayNames(3)).to.deep.equal(expectedResult); 65 | 66 | expectedResult = ['Fri', 'Sat', 'Sun', 'Mon', 'Tue', 'Wed', 'Thu']; 67 | expect(utils.buildDayNames(5)).to.deep.equal(expectedResult); 68 | }); 69 | 70 | }); 71 | 72 | describe('buildDates', function() { 73 | var date, 74 | weekStartsOn, 75 | d = function(date) { 76 | return utils.parseDate(date); 77 | }; 78 | 79 | beforeEach(function() { 80 | date = d('2015-01-01'); 81 | weekStartsOn = 0; 82 | }); 83 | 84 | it('returns the correct dates', function() { 85 | var dates = utils.buildDates(date, { weekStartsOn: weekStartsOn }); 86 | 87 | expect(dates[0]).to.deep.equal(d('2014-12-28')); 88 | expect(dates[3]).to.deep.equal(d('2014-12-31')); 89 | expect(dates[4]).to.deep.equal(d('2015-01-01')); 90 | expect(dates[34]).to.deep.equal(d('2015-01-31')); 91 | expect(dates[35]).to.deep.equal(d('2015-02-01')); 92 | expect(dates.slice(-1)[0]).to.deep.equal(d('2015-02-07')); 93 | }); 94 | 95 | it('has 6 rows of dates by default', function() { 96 | expect(utils.buildDates(date, { weekStartsOn: weekStartsOn })).to.have.length(6 * 7); 97 | }); 98 | 99 | it('should not add empty rows when told not to', function() { 100 | expect(utils.buildDates(date, { weekStartsOn: weekStartsOn, noExtraRows: true })).to.have.length(5 * 7); 101 | }); 102 | 103 | it('adds 2 extra rows when required', function() { 104 | var date = d('2015-02-01'); 105 | 106 | expect(utils.buildDates(date, { weekStartsOn: weekStartsOn, noExtraRows: false })).to.have.length(6 * 7); 107 | expect(utils.buildDates(date, { weekStartsOn: weekStartsOn, noExtraRows: true })).to.have.length(4 * 7); 108 | expect(utils.buildDates(date, { weekStartsOn: 1, noExtraRows: true })).to.have.length(5 * 7); 109 | }); 110 | 111 | it('works when the week starts on monday', function() { 112 | var dates = utils.buildDates(date, { weekStartsOn: 1 }); 113 | 114 | expect(dates[0]).to.deep.equal(d('2014-12-29')); 115 | expect(dates[3]).to.deep.equal(d('2015-01-01')); 116 | expect(dates[33]).to.deep.equal(d('2015-01-31')); 117 | expect(dates.slice(-1)[0]).to.deep.equal(d('2015-02-08')); 118 | }); 119 | 120 | it('works when the week starts on saturday', function() { 121 | var dates = utils.buildDates(date, { weekStartsOn: 6 }); 122 | 123 | expect(dates[0]).to.deep.equal(d('2014-12-27')); 124 | expect(dates[5]).to.deep.equal(d('2015-01-01')); 125 | expect(dates[35]).to.deep.equal(d('2015-01-31')); 126 | expect(dates.slice(-1)[0]).to.deep.equal(d('2015-02-06')); 127 | }); 128 | 129 | }); 130 | 131 | }); 132 | -------------------------------------------------------------------------------- /dist/angular-pickadate.min.js: -------------------------------------------------------------------------------- 1 | !function(e){"use strict";function t(e,t){for(var a=t.parentNode;null!==a;){if(a===e)return!0;a=a.parentNode}return!1}var a=[].indexOf||function(e){for(var t=0,a=this.length;a>t;t++)if(t in this&&this[t]===e)return t;return-1};e.module("pickadate",[]).provider("pickadateI18n",function(){var e={prev:"prev",next:"next"};this.translations={},this.$get=function(){var t=this.translations;return{t:function(a){return t[a]||e[a]}}}}).factory("pickadateUtils",["$locale",function(t){function a(e){switch(e){case"dd":return"day";case"MM":return"month";case"yyyy":return"year"}}return{parseDate:function(t,n){if(t){if(e.isDate(t))return new Date(t);n=n||"yyyy-MM-dd";var i="(dd|MM|yyyy)",r=n.match(/[-|/]/)[0],s=t.split(r),c=new RegExp([i,i,i].join(r)),l=n.match(c),o={};if(l.shift(),e.forEach(l,function(e,t){o[a(e)]=parseInt(s[t],10)}),!(isNaN(o.year)||isNaN(o.month)||isNaN(o.day)))return new Date(o.year,o.month-1,o.day,3)}},buildDates:function(e,t){var a=[],n=new Date(e.getFullYear(),e.getMonth()+1,0,3);for(t=t||{},e=new Date(e);e.getDay()!==t.weekStartsOn;)e.setDate(e.getDate()-1);for(var i=0;42>i&&!(t.noExtraRows&&e.getDay()===t.weekStartsOn&&e>n);i++)a.push(new Date(e)),e.setDate(e.getDate()+1);return a},buildDayNames:function(e){var a=t.DATETIME_FORMATS.SHORTDAY;if(e){a=a.slice(0);for(var n=0;e>n;n++)a.push(a.shift())}return a}}}]).directive("pickadate",["$locale","$sce","$compile","$document","$window","pickadateUtils","pickadateI18n","dateFilter",function(n,i,r,s,c,l,o,d){var u='
  • {{dayName}}
  • {{d.dateObj | date:"d"}}
';return{require:"ngModel",scope:{defaultDate:"=",minDate:"=",maxDate:"=",disabledDates:"=",weekStartsOn:"="},link:function(n,i,o,p){function f(){var e=new Date(n.currentDate.getFullYear(),n.currentDate.getMonth(),1,3),t=e.getMonth()+1,a=l.buildDates(e,{weekStartsOn:$,noExtraRows:m}),i=[],r=d(new Date,O),s=new Date(e);s.setMonth(t),n.allowPrevMonth=!w||e>w,n.allowNextMonth=!k||k>=s,n.dayNames=l.buildDayNames($);for(var c=0;ce||e>k||d(e,"M")!==d(n.currentDate,"M")}function D(e){return a.call(n.disabledDates||[],e)>=0}function g(e,t){var n=a.call(t,e);return-1===n?t.push(e):t.splice(n,1),t}var w,k,m=o.hasOwnProperty("noExtraRows"),M=o.hasOwnProperty("multiple"),$=n.weekStartsOn,b=[],N=i[0]instanceof HTMLInputElement,x=r(u)(n),O=(o.format||"yyyy-MM-dd").replace(/m/g,"M");n.displayPicker=!N,(!e.isNumber($)||0>$||$>6)&&($=0),n.setDate=function(e){v(e.dateObj)||D(e.date)||(b=M?g(e.date,b):[e.date],h(b),n.displayPicker=!N)};var R=p.$render=function(t){t=t||{},e.isArray(p.$viewValue)?b=p.$viewValue:p.$viewValue&&(b=[p.$viewValue]),n.currentDate=l.parseDate(n.defaultDate,O)||l.parseDate(b[0],O)||new Date,b=y(b),h(b,t),f()};if(n.classesFor=function(e){var t=a.call(b,e.date)>=0?"pickadate-active":null;return e.classNames.concat(t)},n.changeMonth=function(e){n.currentDate.setDate(1),n.currentDate.setMonth(n.currentDate.getMonth()+e),f()},n.$watch(function(){return e.toJson([n.minDate,n.maxDate,n.disabledDates])},function(){w=l.parseDate(n.minDate,O)||new Date(0),k=l.parseDate(n.maxDate,O)||new Date(99999999999999),R()}),N){var E=function(e){n.displayPicker=e,n.$apply()};i.on("focus",function(){var e=void 0!==c.pageXOffset,t="CSS1Compat"===(s.compatMode||""),a=e?c.pageXOffset:t?s.documentElement.scrollLeft:s.body.scrollLeft,r=e?c.pageYOffset:t?s.documentElement.scrollTop:s.body.scrollTop,l=c.innerWidth||s.documentElement.clientWidth||s.body.clientWidth;n.styles={top:r+i[0].getBoundingClientRect().bottom+"px"},l-i[0].getBoundingClientRect().left>=300?n.styles.left=a+i[0].getBoundingClientRect().left+"px":n.styles.right=l-i[0].getBoundingClientRect().right-a+"px",E(!0)}),i.on("keydown",function(e){a.call([9,13,27],e.keyCode)>=0&&E(!1)}),n.$watch(function(){return p.$viewValue},function(e){var t=l.parseDate(e,O);t&&R({skipRenderInput:!0}),p.$setValidity("date",!!t)}),s.on("click",function(e){t(x[0],e.target)||e.target===i[0]||E(!1)}),n.$$postDigest(function(){o.value&&(p.$viewValue=o.value,R())}),i.after(x.addClass("pickadate-modal"))}else i.append(x)}}}])}(window.angular); -------------------------------------------------------------------------------- /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, disabled-dates, 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 | ```html 70 |
71 | ``` 72 | 73 | ```javascript 74 | function MyAppController($scope) { 75 | $scope.disabledDates = ['2013-11-10', '2013-11-15', '2013-11-19']; 76 | } 77 | ``` 78 | 79 | #### default-date 80 | 81 | Allows you to preset the calendar to a particular month without setting the chosen date. 82 | 83 | ```html 84 |
85 | ``` 86 | 87 | ```javascript 88 | function MyAppController($scope) { 89 | $scope.presetDate = '2013-12-01'; 90 | } 91 | ``` 92 | 93 | #### week-starts-on 94 | 95 | Sets the first day of the week. The default is 0 for Sunday. 96 | 97 | ```html 98 |
99 | ``` 100 | 101 | #### no-extra-rows 102 | 103 | The calendar will have between 4 and 6 rows if this attribute is present. By default it will always have 6 rows. 104 | 105 | ```html 106 |
107 | ``` 108 | 109 | #### multiple 110 | 111 | The calendar will support selecting multiple dates. NgModel will be set as an array of date strings 112 | 113 | ```html 114 |
115 | ``` 116 | 117 | ### I18n & Icons 118 | 119 | 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/). 120 | 121 | For the remaining translations you can configure the `pickadateI18nProvider`. 122 | 123 | ```javascript 124 | angular.module('testApp', ['pickadate']) 125 | 126 | .config(function(pickadateI18nProvider) { 127 | pickadateI18nProvider.translations = { 128 | prev: ' ant', 129 | next: 'sig ' 130 | } 131 | }); 132 | ``` 133 | 134 | The translations can contain custom html code, useful to include custom icons in the calendar controls. 135 | 136 | ## License 137 | 138 | Copyright (c) 2013 Restorando 139 | 140 | MIT License 141 | 142 | Permission is hereby granted, free of charge, to any person obtaining 143 | a copy of this software and associated documentation files (the 144 | "Software"), to deal in the Software without restriction, including 145 | without limitation the rights to use, copy, modify, merge, publish, 146 | distribute, sublicense, and/or sell copies of the Software, and to 147 | permit persons to whom the Software is furnished to do so, subject to 148 | the following conditions: 149 | 150 | The above copyright notice and this permission notice shall be 151 | included in all copies or substantial portions of the Software. 152 | 153 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 154 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 155 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 156 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 157 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 158 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 159 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 160 | 161 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 isDescendant(parent, child) { 11 | var node = child.parentNode; 12 | while (node !== null) { 13 | if (node === parent) return true; 14 | node = node.parentNode; 15 | } 16 | return false; 17 | } 18 | 19 | angular.module('pickadate', []) 20 | 21 | .provider('pickadateI18n', function() { 22 | var defaults = { 23 | 'prev': 'prev', 24 | 'next': 'next' 25 | }; 26 | 27 | this.translations = {}; 28 | 29 | this.$get = function() { 30 | var translations = this.translations; 31 | 32 | return { 33 | t: function(key) { 34 | return translations[key] || defaults[key]; 35 | } 36 | }; 37 | }; 38 | }) 39 | 40 | .factory('pickadateUtils', ['$locale', function($locale) { 41 | 42 | function getPartName(part) { 43 | switch (part) { 44 | case 'dd': return 'day'; 45 | case 'MM': return 'month'; 46 | case 'yyyy': return 'year'; 47 | } 48 | } 49 | 50 | return { 51 | parseDate: function(dateString, format) { 52 | if (!dateString) return; 53 | if (angular.isDate(dateString)) return new Date(dateString); 54 | 55 | format = format || 'yyyy-MM-dd'; 56 | 57 | var formatRegex = '(dd|MM|yyyy)', 58 | separator = format.match(/[-|/]/)[0], 59 | dateParts = dateString.split(separator), 60 | regexp = new RegExp([formatRegex, formatRegex, formatRegex].join(separator)), 61 | formatParts = format.match(regexp), 62 | dateObj = {}; 63 | 64 | formatParts.shift(); 65 | 66 | angular.forEach(formatParts, function(part, i) { 67 | dateObj[getPartName(part)] = parseInt(dateParts[i], 10); 68 | }); 69 | 70 | if (isNaN(dateObj.year) || isNaN(dateObj.month) || isNaN(dateObj.day)) return; 71 | 72 | return new Date(dateObj.year, dateObj.month - 1, dateObj.day, 3); 73 | }, 74 | 75 | buildDates: function(date, options) { 76 | var dates = [], 77 | lastDate = new Date(date.getFullYear(), date.getMonth() + 1, 0, 3); 78 | 79 | options = options || {}; 80 | date = new Date(date); 81 | 82 | while (date.getDay() !== options.weekStartsOn) { 83 | date.setDate(date.getDate() - 1); 84 | } 85 | 86 | for (var i = 0; i < 42; i++) { // 42 == 6 rows of dates 87 | if (options.noExtraRows && date.getDay() === options.weekStartsOn && date > lastDate) break; 88 | 89 | dates.push(new Date(date)); 90 | date.setDate(date.getDate() + 1); 91 | } 92 | 93 | return dates; 94 | }, 95 | 96 | buildDayNames: function(weekStartsOn) { 97 | var dayNames = $locale.DATETIME_FORMATS.SHORTDAY; 98 | 99 | if (weekStartsOn) { 100 | dayNames = dayNames.slice(0); 101 | for (var i = 0; i < weekStartsOn; i++) { 102 | dayNames.push(dayNames.shift()); 103 | } 104 | } 105 | return dayNames; 106 | } 107 | }; 108 | }]) 109 | 110 | .directive('pickadate', ['$locale', '$sce', '$compile', '$document', '$window', 'pickadateUtils', 111 | 'pickadateI18n', 'dateFilter', function($locale, $sce, $compile, $document, $window, dateUtils, i18n, dateFilter) { 112 | 113 | var TEMPLATE = 114 | '
' + 115 | '
' + 116 | ''+ 124 | '

' + 125 | '{{currentDate | date:"MMMM yyyy"}}' + 126 | '

' + 127 | '
' + 128 | '
' + 129 | '
' + 130 | '
    ' + 131 | '
  • ' + 132 | '{{dayName}}' + 133 | '
  • ' + 134 | '
' + 135 | '
    ' + 136 | '
  • ' + 137 | '{{d.dateObj | date:"d"}}' + 138 | '
  • ' + 139 | '
' + 140 | '
' + 141 | '
' + 142 | '
'; 143 | 144 | return { 145 | require: 'ngModel', 146 | scope: { 147 | defaultDate: '=', 148 | minDate: '=', 149 | maxDate: '=', 150 | disabledDates: '=', 151 | weekStartsOn: '=', 152 | }, 153 | 154 | link: function(scope, element, attrs, ngModel) { 155 | var noExtraRows = attrs.hasOwnProperty('noExtraRows'), 156 | allowMultiple = attrs.hasOwnProperty('multiple'), 157 | weekStartsOn = scope.weekStartsOn, 158 | selectedDates = [], 159 | wantsModal = element[0] instanceof HTMLInputElement, 160 | compiledHtml = $compile(TEMPLATE)(scope), 161 | format = (attrs.format || 'yyyy-MM-dd').replace(/m/g, 'M'), 162 | minDate, maxDate; 163 | 164 | scope.displayPicker = !wantsModal; 165 | 166 | if (!angular.isNumber(weekStartsOn) || weekStartsOn < 0 || weekStartsOn > 6) { 167 | weekStartsOn = 0; 168 | } 169 | 170 | scope.setDate = function(dateObj) { 171 | if (isOutOfRange(dateObj.dateObj) || isDateDisabled(dateObj.date)) return; 172 | selectedDates = allowMultiple ? toggleDate(dateObj.date, selectedDates) : [dateObj.date]; 173 | setViewValue(selectedDates); 174 | scope.displayPicker = !wantsModal; 175 | }; 176 | 177 | var $render = ngModel.$render = function(options) { 178 | options = options || {}; 179 | 180 | if (angular.isArray(ngModel.$viewValue)) { 181 | selectedDates = ngModel.$viewValue; 182 | } else if (ngModel.$viewValue) { 183 | selectedDates = [ngModel.$viewValue]; 184 | } 185 | 186 | scope.currentDate = dateUtils.parseDate(scope.defaultDate, format) || 187 | dateUtils.parseDate(selectedDates[0], format) || new Date(); 188 | 189 | selectedDates = enabledDatesOf(selectedDates); 190 | 191 | setViewValue(selectedDates, options); 192 | render(); 193 | }; 194 | 195 | scope.classesFor = function(date) { 196 | var extraClasses = indexOf.call(selectedDates, date.date) >= 0 ? 'pickadate-active' : null; 197 | return date.classNames.concat(extraClasses); 198 | }; 199 | 200 | scope.changeMonth = function(offset) { 201 | // If the current date is January 31th, setting the month to date.getMonth() + 1 202 | // sets the date to March the 3rd, since the date object adds 30 days to the current 203 | // date. Settings the date to the 2nd day of the month is a workaround to prevent this 204 | // behaviour 205 | scope.currentDate.setDate(1); 206 | scope.currentDate.setMonth(scope.currentDate.getMonth() + offset); 207 | render(); 208 | }; 209 | 210 | // Workaround to watch multiple properties. XXX use $scope.$watchGroup in angular 1.3 211 | scope.$watch(function(){ 212 | return angular.toJson([scope.minDate, scope.maxDate, scope.disabledDates]); 213 | }, function() { 214 | minDate = dateUtils.parseDate(scope.minDate, format) || new Date(0); 215 | maxDate = dateUtils.parseDate(scope.maxDate, format) || new Date(99999999999999); 216 | 217 | $render(); 218 | }); 219 | 220 | // Insert datepicker into DOM 221 | if (wantsModal) { 222 | var togglePicker = function(toggle) { 223 | scope.displayPicker = toggle; 224 | scope.$apply(); 225 | }; 226 | 227 | element.on('focus', function() { 228 | var supportPageOffset = $window.pageXOffset !== undefined, 229 | isCSS1Compat = (($document.compatMode || "") === "CSS1Compat"), 230 | scrollX = supportPageOffset ? $window.pageXOffset : isCSS1Compat ? $document.documentElement.scrollLeft : $document.body.scrollLeft, 231 | scrollY = supportPageOffset ? $window.pageYOffset : isCSS1Compat ? $document.documentElement.scrollTop : $document.body.scrollTop, 232 | innerWidth = $window.innerWidth || $document.documentElement.clientWidth || $document.body.clientWidth; 233 | 234 | scope.styles = { top: scrollY + element[0].getBoundingClientRect().bottom + 'px' }; 235 | 236 | if ((innerWidth - element[0].getBoundingClientRect().left ) >= 300) { 237 | scope.styles.left = scrollX + element[0].getBoundingClientRect().left + 'px'; 238 | } else { 239 | scope.styles.right = innerWidth - element[0].getBoundingClientRect().right - scrollX + 'px'; 240 | } 241 | 242 | togglePicker(true); 243 | }); 244 | 245 | element.on('keydown', function(e) { 246 | if (indexOf.call([9, 13, 27], e.keyCode) >= 0) togglePicker(false); 247 | }); 248 | 249 | // if the user types a date, update the picker and set validity 250 | scope.$watch(function() { 251 | return ngModel.$viewValue; 252 | }, function(val) { 253 | var isValidDate = dateUtils.parseDate(val, format); 254 | 255 | if (isValidDate) $render({ skipRenderInput: true }); 256 | ngModel.$setValidity('date', !!isValidDate); 257 | }); 258 | 259 | $document.on('click', function(e) { 260 | if (isDescendant(compiledHtml[0], e.target) || e.target === element[0]) return; 261 | togglePicker(false); 262 | }); 263 | 264 | // if the input element has a value, set it as the ng-model 265 | scope.$$postDigest(function() { 266 | if (attrs.value) { ngModel.$viewValue = attrs.value; $render(); } 267 | }); 268 | 269 | element.after(compiledHtml.addClass('pickadate-modal')); 270 | } else { 271 | element.append(compiledHtml); 272 | } 273 | 274 | function render() { 275 | var initialDate = new Date(scope.currentDate.getFullYear(), scope.currentDate.getMonth(), 1, 3), 276 | currentMonth = initialDate.getMonth() + 1, 277 | allDates = dateUtils.buildDates(initialDate, { weekStartsOn: weekStartsOn, noExtraRows: noExtraRows }), 278 | dates = [], 279 | today = dateFilter(new Date(), format); 280 | 281 | var nextMonthInitialDate = new Date(initialDate); 282 | nextMonthInitialDate.setMonth(currentMonth); 283 | 284 | scope.allowPrevMonth = !minDate || initialDate > minDate; 285 | scope.allowNextMonth = !maxDate || nextMonthInitialDate <= maxDate; 286 | scope.dayNames = dateUtils.buildDayNames(weekStartsOn); 287 | 288 | for (var i = 0; i < allDates.length; i++) { 289 | var classNames = [], 290 | dateObj = allDates[i], 291 | date = dateFilter(dateObj, format), 292 | isDisabled = isDateDisabled(date); 293 | 294 | if (isOutOfRange(dateObj) || isDisabled) { 295 | classNames.push('pickadate-disabled'); 296 | } else { 297 | classNames.push('pickadate-enabled'); 298 | } 299 | 300 | if (isDisabled) classNames.push('pickadate-unavailable'); 301 | if (date === today) classNames.push('pickadate-today'); 302 | 303 | dates.push({date: date, dateObj: dateObj, classNames: classNames}); 304 | } 305 | 306 | scope.dates = dates; 307 | } 308 | 309 | function setViewValue(value, options) { 310 | options = options || {}; 311 | 312 | if (allowMultiple) { 313 | ngModel.$setViewValue(value); 314 | } else { 315 | ngModel.$setViewValue(value[0]); 316 | } 317 | if (!options.skipRenderInput) element.val(ngModel.$viewValue); 318 | } 319 | 320 | function enabledDatesOf(dateArray) { 321 | var resultArray = []; 322 | 323 | for (var i = 0; i < dateArray.length; i++) { 324 | var date = dateArray[i]; 325 | 326 | if (!isDateDisabled(date) && !isOutOfRange(dateUtils.parseDate(date, format))) { 327 | resultArray.push(date); 328 | } 329 | } 330 | 331 | return resultArray; 332 | } 333 | 334 | function isOutOfRange(date) { 335 | return date < minDate || date > maxDate || dateFilter(date, 'M') !== dateFilter(scope.currentDate, 'M'); 336 | } 337 | 338 | function isDateDisabled(date) { 339 | return indexOf.call(scope.disabledDates || [], date) >= 0; 340 | } 341 | 342 | function toggleDate(date, dateArray) { 343 | var index = indexOf.call(dateArray, date); 344 | if (index === -1) { 345 | dateArray.push(date); 346 | } 347 | else { 348 | dateArray.splice(index, 1); 349 | } 350 | return dateArray; 351 | } 352 | } 353 | }; 354 | }]); 355 | })(window.angular); 356 | -------------------------------------------------------------------------------- /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 isDescendant(parent, child) { 11 | var node = child.parentNode; 12 | while (node !== null) { 13 | if (node === parent) return true; 14 | node = node.parentNode; 15 | } 16 | return false; 17 | } 18 | 19 | angular.module('pickadate', []) 20 | 21 | .provider('pickadateI18n', function() { 22 | var defaults = { 23 | 'prev': 'prev', 24 | 'next': 'next' 25 | }; 26 | 27 | this.translations = {}; 28 | 29 | this.$get = function() { 30 | var translations = this.translations; 31 | 32 | return { 33 | t: function(key) { 34 | return translations[key] || defaults[key]; 35 | } 36 | }; 37 | }; 38 | }) 39 | 40 | .factory('pickadateUtils', ['$locale', function($locale) { 41 | 42 | function getPartName(part) { 43 | switch (part) { 44 | case 'dd': return 'day'; 45 | case 'MM': return 'month'; 46 | case 'yyyy': return 'year'; 47 | } 48 | } 49 | 50 | return { 51 | parseDate: function(dateString, format) { 52 | if (!dateString) return; 53 | if (angular.isDate(dateString)) return new Date(dateString); 54 | 55 | format = format || 'yyyy-MM-dd'; 56 | 57 | var formatRegex = '(dd|MM|yyyy)', 58 | separator = format.match(/[-|/]/)[0], 59 | dateParts = dateString.split(separator), 60 | regexp = new RegExp([formatRegex, formatRegex, formatRegex].join(separator)), 61 | formatParts = format.match(regexp), 62 | dateObj = {}; 63 | 64 | formatParts.shift(); 65 | 66 | angular.forEach(formatParts, function(part, i) { 67 | dateObj[getPartName(part)] = parseInt(dateParts[i], 10); 68 | }); 69 | 70 | if (isNaN(dateObj.year) || isNaN(dateObj.month) || isNaN(dateObj.day)) return; 71 | 72 | return new Date(dateObj.year, dateObj.month - 1, dateObj.day, 3); 73 | }, 74 | 75 | buildDates: function(date, options) { 76 | var dates = [], 77 | lastDate = new Date(date.getFullYear(), date.getMonth() + 1, 0, 3); 78 | 79 | options = options || {}; 80 | date = new Date(date); 81 | 82 | while (date.getDay() !== options.weekStartsOn) { 83 | date.setDate(date.getDate() - 1); 84 | } 85 | 86 | for (var i = 0; i < 42; i++) { // 42 == 6 rows of dates 87 | if (options.noExtraRows && date.getDay() === options.weekStartsOn && date > lastDate) break; 88 | 89 | dates.push(new Date(date)); 90 | date.setDate(date.getDate() + 1); 91 | } 92 | 93 | return dates; 94 | }, 95 | 96 | buildDayNames: function(weekStartsOn) { 97 | var dayNames = $locale.DATETIME_FORMATS.SHORTDAY; 98 | 99 | if (weekStartsOn) { 100 | dayNames = dayNames.slice(0); 101 | for (var i = 0; i < weekStartsOn; i++) { 102 | dayNames.push(dayNames.shift()); 103 | } 104 | } 105 | return dayNames; 106 | } 107 | }; 108 | }]) 109 | 110 | .directive('pickadate', ['$locale', '$sce', '$compile', '$document', '$window', 'pickadateUtils', 111 | 'pickadateI18n', 'dateFilter', function($locale, $sce, $compile, $document, $window, dateUtils, i18n, dateFilter) { 112 | 113 | var TEMPLATE = 114 | '
' + 115 | '
' + 116 | ''+ 124 | '

' + 125 | '{{currentDate | date:"MMMM yyyy"}}' + 126 | '

' + 127 | '
' + 128 | '
' + 129 | '
' + 130 | '
    ' + 131 | '
  • ' + 132 | '{{dayName}}' + 133 | '
  • ' + 134 | '
' + 135 | '
    ' + 136 | '
  • ' + 137 | '{{d.dateObj | date:"d"}}' + 138 | '
  • ' + 139 | '
' + 140 | '
' + 141 | '
' + 142 | '
'; 143 | 144 | return { 145 | require: 'ngModel', 146 | scope: { 147 | defaultDate: '=', 148 | minDate: '=', 149 | maxDate: '=', 150 | disabledDates: '=', 151 | weekStartsOn: '=', 152 | }, 153 | 154 | link: function(scope, element, attrs, ngModel) { 155 | var noExtraRows = attrs.hasOwnProperty('noExtraRows'), 156 | allowMultiple = attrs.hasOwnProperty('multiple'), 157 | weekStartsOn = scope.weekStartsOn, 158 | selectedDates = [], 159 | wantsModal = element[0] instanceof HTMLInputElement, 160 | compiledHtml = $compile(TEMPLATE)(scope), 161 | format = (attrs.format || 'yyyy-MM-dd').replace(/m/g, 'M'), 162 | minDate, maxDate; 163 | 164 | scope.displayPicker = !wantsModal; 165 | 166 | if (!angular.isNumber(weekStartsOn) || weekStartsOn < 0 || weekStartsOn > 6) { 167 | weekStartsOn = 0; 168 | } 169 | 170 | scope.setDate = function(dateObj) { 171 | if (isOutOfRange(dateObj.dateObj) || isDateDisabled(dateObj.date)) return; 172 | selectedDates = allowMultiple ? toggleDate(dateObj.date, selectedDates) : [dateObj.date]; 173 | setViewValue(selectedDates); 174 | scope.displayPicker = !wantsModal; 175 | }; 176 | 177 | var $render = ngModel.$render = function(options) { 178 | options = options || {}; 179 | 180 | if (angular.isArray(ngModel.$viewValue)) { 181 | selectedDates = ngModel.$viewValue; 182 | } else if (ngModel.$viewValue) { 183 | selectedDates = [ngModel.$viewValue]; 184 | } 185 | 186 | scope.currentDate = dateUtils.parseDate(scope.defaultDate, format) || 187 | dateUtils.parseDate(selectedDates[0], format) || new Date(); 188 | 189 | selectedDates = enabledDatesOf(selectedDates); 190 | 191 | setViewValue(selectedDates, options); 192 | render(); 193 | }; 194 | 195 | scope.classesFor = function(date) { 196 | var extraClasses = indexOf.call(selectedDates, date.date) >= 0 ? 'pickadate-active' : null; 197 | return date.classNames.concat(extraClasses); 198 | }; 199 | 200 | scope.changeMonth = function(offset) { 201 | // If the current date is January 31th, setting the month to date.getMonth() + 1 202 | // sets the date to March the 3rd, since the date object adds 30 days to the current 203 | // date. Settings the date to the 2nd day of the month is a workaround to prevent this 204 | // behaviour 205 | scope.currentDate.setDate(1); 206 | scope.currentDate.setMonth(scope.currentDate.getMonth() + offset); 207 | render(); 208 | }; 209 | 210 | // Workaround to watch multiple properties. XXX use $scope.$watchGroup in angular 1.3 211 | scope.$watch(function(){ 212 | return angular.toJson([scope.minDate, scope.maxDate, scope.disabledDates]); 213 | }, function() { 214 | minDate = dateUtils.parseDate(scope.minDate, format) || new Date(0); 215 | maxDate = dateUtils.parseDate(scope.maxDate, format) || new Date(99999999999999); 216 | 217 | $render(); 218 | }); 219 | 220 | // Insert datepicker into DOM 221 | if (wantsModal) { 222 | var togglePicker = function(toggle) { 223 | scope.displayPicker = toggle; 224 | scope.$apply(); 225 | }; 226 | 227 | element.on('focus', function() { 228 | var supportPageOffset = $window.pageXOffset !== undefined, 229 | isCSS1Compat = (($document.compatMode || "") === "CSS1Compat"), 230 | scrollX = supportPageOffset ? $window.pageXOffset : isCSS1Compat ? $document.documentElement.scrollLeft : $document.body.scrollLeft, 231 | scrollY = supportPageOffset ? $window.pageYOffset : isCSS1Compat ? $document.documentElement.scrollTop : $document.body.scrollTop, 232 | innerWidth = $window.innerWidth || $document.documentElement.clientWidth || $document.body.clientWidth; 233 | 234 | scope.styles = { top: scrollY + element[0].getBoundingClientRect().bottom + 'px' }; 235 | 236 | if ((innerWidth - element[0].getBoundingClientRect().left ) >= 300) { 237 | scope.styles.left = scrollX + element[0].getBoundingClientRect().left + 'px'; 238 | } else { 239 | scope.styles.right = innerWidth - element[0].getBoundingClientRect().right - scrollX + 'px'; 240 | } 241 | 242 | togglePicker(true); 243 | }); 244 | 245 | element.on('keydown', function(e) { 246 | if (indexOf.call([9, 13, 27], e.keyCode) >= 0) togglePicker(false); 247 | }); 248 | 249 | // if the user types a date, update the picker and set validity 250 | scope.$watch(function() { 251 | return ngModel.$viewValue; 252 | }, function(val) { 253 | var isValidDate = dateUtils.parseDate(val, format); 254 | 255 | if (isValidDate) $render({ skipRenderInput: true }); 256 | ngModel.$setValidity('date', !!isValidDate); 257 | }); 258 | 259 | $document.on('click', function(e) { 260 | if (isDescendant(compiledHtml[0], e.target) || e.target === element[0]) return; 261 | togglePicker(false); 262 | }); 263 | 264 | // if the input element has a value, set it as the ng-model 265 | scope.$$postDigest(function() { 266 | if (attrs.value) { ngModel.$viewValue = attrs.value; $render(); } 267 | }); 268 | 269 | element.after(compiledHtml.addClass('pickadate-modal')); 270 | } else { 271 | element.append(compiledHtml); 272 | } 273 | 274 | function render() { 275 | var initialDate = new Date(scope.currentDate.getFullYear(), scope.currentDate.getMonth(), 1, 3), 276 | currentMonth = initialDate.getMonth() + 1, 277 | allDates = dateUtils.buildDates(initialDate, { weekStartsOn: weekStartsOn, noExtraRows: noExtraRows }), 278 | dates = [], 279 | today = dateFilter(new Date(), format); 280 | 281 | var nextMonthInitialDate = new Date(initialDate); 282 | nextMonthInitialDate.setMonth(currentMonth); 283 | 284 | scope.allowPrevMonth = !minDate || initialDate > minDate; 285 | scope.allowNextMonth = !maxDate || nextMonthInitialDate <= maxDate; 286 | scope.dayNames = dateUtils.buildDayNames(weekStartsOn); 287 | 288 | for (var i = 0; i < allDates.length; i++) { 289 | var classNames = [], 290 | dateObj = allDates[i], 291 | date = dateFilter(dateObj, format), 292 | isDisabled = isDateDisabled(date); 293 | 294 | if (isOutOfRange(dateObj) || isDisabled) { 295 | classNames.push('pickadate-disabled'); 296 | } else { 297 | classNames.push('pickadate-enabled'); 298 | } 299 | 300 | if (isDisabled) classNames.push('pickadate-unavailable'); 301 | if (date === today) classNames.push('pickadate-today'); 302 | 303 | dates.push({date: date, dateObj: dateObj, classNames: classNames}); 304 | } 305 | 306 | scope.dates = dates; 307 | } 308 | 309 | function setViewValue(value, options) { 310 | options = options || {}; 311 | 312 | if (allowMultiple) { 313 | ngModel.$setViewValue(value); 314 | } else { 315 | ngModel.$setViewValue(value[0]); 316 | } 317 | if (!options.skipRenderInput) element.val(ngModel.$viewValue); 318 | } 319 | 320 | function enabledDatesOf(dateArray) { 321 | var resultArray = []; 322 | 323 | for (var i = 0; i < dateArray.length; i++) { 324 | var date = dateArray[i]; 325 | 326 | if (!isDateDisabled(date) && !isOutOfRange(dateUtils.parseDate(date, format))) { 327 | resultArray.push(date); 328 | } 329 | } 330 | 331 | return resultArray; 332 | } 333 | 334 | function isOutOfRange(date) { 335 | return date < minDate || date > maxDate || dateFilter(date, 'M') !== dateFilter(scope.currentDate, 'M'); 336 | } 337 | 338 | function isDateDisabled(date) { 339 | return indexOf.call(scope.disabledDates || [], date) >= 0; 340 | } 341 | 342 | function toggleDate(date, dateArray) { 343 | var index = indexOf.call(dateArray, date); 344 | if (index === -1) { 345 | dateArray.push(date); 346 | } 347 | else { 348 | dateArray.splice(index, 1); 349 | } 350 | return dateArray; 351 | } 352 | } 353 | }; 354 | }]); 355 | })(window.angular); 356 | -------------------------------------------------------------------------------- /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 = '
' + 13 | '
'; 14 | 15 | beforeEach(module('pickadate')); 16 | 17 | beforeEach(module(function(_pickadateI18nProvider_) { 18 | pickadateI18nProvider = _pickadateI18nProvider_; 19 | })); 20 | 21 | beforeEach(function() { 22 | inject(function($rootScope, _$compile_, _$document_){ 23 | $scope = $rootScope.$new(); 24 | $compile = _$compile_; 25 | $document = _$document_; 26 | }); 27 | }); 28 | 29 | function compile(html) { 30 | element = angular.element(html || defaultHtml); 31 | $compile(element)($scope); 32 | $scope.$digest(); 33 | } 34 | 35 | function $(selector) { 36 | return jQuery(selector, element); 37 | } 38 | 39 | describe('Model binding', function() { 40 | 41 | beforeEach(function() { 42 | $scope.date = '2014-05-17'; 43 | $scope.disabledDates = ['2014-05-26']; 44 | compile(); 45 | }); 46 | 47 | it("updates the ngModel value when a date is clicked", function() { 48 | expect($scope.date).to.equal('2014-05-17'); 49 | browserTrigger($('.pickadate-enabled:contains(27)'), 'click'); 50 | expect($scope.date).to.equal('2014-05-27'); 51 | }); 52 | 53 | it("doesn't allow an unavailable date to be clicked", function() { 54 | expect($scope.date).to.equal('2014-05-17'); 55 | browserTrigger($('.pickadate-enabled:contains(26)'), 'click'); 56 | expect($scope.date).to.equal('2014-05-17'); 57 | }); 58 | 59 | it("sets the ngModel as undefined if the model date is in the disabled list", function() { 60 | $scope.date = '2014-05-26'; 61 | $scope.$digest(); 62 | expect($scope.date).to.be.undefined; 63 | }); 64 | 65 | }); 66 | 67 | describe('Rendering', function() { 68 | 69 | beforeEach(function() { 70 | $scope.date = '2014-05-17'; 71 | $scope.disabledDates = ['2014-05-26']; 72 | compile(); 73 | }); 74 | 75 | describe('Selected date', function() { 76 | 77 | it("doesn't add the pickadate-modal class", function() { 78 | expect($('.pickadate')).not.to.have.class('pickadate-modal'); 79 | }); 80 | 81 | it("adds the 'pickadate-active' class for the selected date", function() { 82 | expect($('.pickadate-active')).to.have.text('17'); 83 | expect($('.pickadate-active').length).to.equal(1); 84 | 85 | browserTrigger($('.pickadate-enabled:contains(27)'), 'click'); 86 | 87 | expect($('.pickadate-active')).to.have.text('27'); 88 | expect($('.pickadate-active').length).to.equal(1); 89 | }); 90 | 91 | it("doesn't have an element with the 'pickadate-active' class for the next month", function() { 92 | browserTrigger($('.pickadate-next'), 'click'); 93 | expect($('.pickadate-active').length).to.be.empty; 94 | }); 95 | 96 | it("doesn't change the active element when a disabled date is clicked", function() { 97 | browserTrigger($('.pickadate-enabled:contains(26)'), 'click'); 98 | expect($('.pickadate-active')).to.have.text('17'); 99 | expect($('.pickadate-active').length).to.equal(1); 100 | }); 101 | 102 | }); 103 | 104 | describe('Disabled dates', function() { 105 | 106 | beforeEach(function() { 107 | $scope.disabledDates = ['2014-05-20', '2014-05-26']; 108 | compile(); 109 | }); 110 | 111 | it("adds the 'pickadate-unavailable' class to the disabled dates", function() { 112 | expect($('li:contains(20)')).to.have.class('pickadate-unavailable'); 113 | expect($('li:contains(26)')).to.have.class('pickadate-unavailable'); 114 | }); 115 | 116 | it("doesn't change the selected date if an unavailable one is clicked", function() { 117 | browserTrigger($('.pickadate-unavailable:contains(20)'), 'click'); 118 | expect($scope.date).to.equal('2014-05-17'); 119 | }); 120 | 121 | }); 122 | 123 | describe('Watchers', function() { 124 | 125 | describe('Min && max date', function() { 126 | 127 | beforeEach(function() { 128 | $scope.minDate = '2014-04-20'; 129 | $scope.maxDate = '2014-06-20'; 130 | $scope.date = '2014-05-17'; 131 | compile(); 132 | }); 133 | 134 | it("re-renders the calendar if min-date is updated", function() { 135 | expect($('li:contains(14)')).not.to.have.class('pickadate-disabled'); 136 | 137 | $scope.minDate = '2014-05-15'; 138 | $scope.$digest(); 139 | 140 | expect($('li:contains(14)')).to.have.class('pickadate-disabled'); 141 | expect($('li:contains(15)')).not.to.have.class('pickadate-disabled'); 142 | }); 143 | 144 | it("re-renders the calendar if max-date is updated", function() { 145 | expect($('li:contains(23)')).not.to.have.class('pickadate-disabled'); 146 | 147 | $scope.maxDate = '2014-05-22'; 148 | $scope.$digest(); 149 | 150 | expect($('li:contains(22)')).not.to.have.class('pickadate-disabled'); 151 | expect($('li:contains(23)')).to.have.class('pickadate-disabled'); 152 | }); 153 | 154 | it("unselects the current date if it's not in the min-date - max-date range", function() { 155 | $scope.minDate = '2014-05-19'; 156 | $scope.$digest(); 157 | 158 | expect($scope.date).to.be.undefined; 159 | }); 160 | 161 | it("re-renders the calendar on the selected date", function() { 162 | $scope.date = '2014-06-20'; 163 | $scope.$digest(); 164 | 165 | expect($('.pickadate-centered-heading')).to.have.text('June 2014'); 166 | expect($('li:contains(20)')).to.have.class('pickadate-active'); 167 | }); 168 | 169 | }); 170 | 171 | describe('Disabled dates', function() { 172 | 173 | it("re-renders the calendar if disabled-dates is updated", function() { 174 | compile(); 175 | expect($('li:contains(26)')).to.have.class('pickadate-unavailable'); 176 | 177 | $scope.disabledDates.pop(); 178 | $scope.$digest(); 179 | 180 | expect($('li:contains(26)')).not.to.have.class('pickadate-unavailable'); 181 | }); 182 | 183 | }); 184 | 185 | }); 186 | 187 | }); 188 | 189 | describe('Month Navigation', function() { 190 | 191 | beforeEach(function() { 192 | $scope.date = '2014-05-17'; 193 | }); 194 | 195 | it("renders the current month", function(){ 196 | compile(); 197 | expect($('.pickadate-centered-heading')).to.have.text('May 2014'); 198 | }); 199 | 200 | it("changes to the previous month", function() { 201 | compile(); 202 | 203 | browserTrigger($('.pickadate-prev'), 'click'); 204 | expect($('.pickadate-centered-heading')).to.have.text('April 2014'); 205 | 206 | browserTrigger($('.pickadate-prev'), 'click'); 207 | expect($('.pickadate-centered-heading')).to.have.text('March 2014'); 208 | }); 209 | 210 | it("changes to the next month", function() { 211 | compile(); 212 | 213 | browserTrigger($('.pickadate-next'), 'click'); 214 | expect($('.pickadate-centered-heading')).to.have.text('June 2014'); 215 | 216 | browserTrigger($('.pickadate-next'), 'click'); 217 | expect($('.pickadate-centered-heading')).to.have.text('July 2014'); 218 | }); 219 | 220 | describe('Disabled months', function() { 221 | 222 | it("doesn't render the prev button if prev month < minDate", function() { 223 | $scope.minDate = '2014-04-04'; 224 | compile(); 225 | 226 | expect($('.pickadate-prev')).not.to.have.class('ng-hide'); 227 | 228 | browserTrigger($('.pickadate-prev'), 'click'); 229 | expect($('.pickadate-centered-heading')).to.have.text('April 2014'); 230 | 231 | expect($('.pickadate-prev')).to.have.class('ng-hide'); 232 | }); 233 | 234 | it("doesn't render the next button if next month > maxDate", function() { 235 | $scope.maxDate = '2014-06-04'; 236 | compile(); 237 | 238 | expect($('.pickadate-next')).not.to.have.class('ng-hide'); 239 | 240 | browserTrigger($('.pickadate-next'), 'click'); 241 | expect($('.pickadate-centered-heading')).to.have.text('June 2014'); 242 | 243 | expect($('.pickadate-next')).to.have.class('ng-hide'); 244 | }); 245 | 246 | it("renders the next button if maxDate is set to the beginning of a month", function() { 247 | $scope.date = '2014-08-31'; 248 | $scope.maxDate = '2014-09-01'; 249 | compile(); 250 | 251 | expect($('.pickadate-next')).not.to.have.class('ng-hide'); 252 | }); 253 | 254 | }); 255 | }); 256 | 257 | describe('Configure the first day of the week', function() { 258 | var defaultDay; 259 | 260 | var firstCalendarDay = function(weekStartsOn) { 261 | $scope.weekStartsOn = weekStartsOn; 262 | compile(); 263 | return $('ul:last-child li:first-child').text(); 264 | }; 265 | 266 | beforeEach(function() { 267 | defaultDay = firstCalendarDay(0); 268 | }); 269 | 270 | it('changes the first day of the week', function() { 271 | for (var weekStartsOn = 1; weekStartsOn < 7; weekStartsOn++) { 272 | expect(firstCalendarDay(weekStartsOn)).to.not.equal(defaultDay); 273 | } 274 | }); 275 | 276 | it('sets weekStartsOn to 0 if it is invalid', function() { 277 | angular.forEach([7, -1, 'foo'], function(weekStartsOn) { 278 | expect(firstCalendarDay(weekStartsOn)).to.equal(defaultDay); 279 | }); 280 | }); 281 | 282 | }); 283 | 284 | describe('Default date', function() { 285 | beforeEach(function() { 286 | this.clock = sinon.useFakeTimers(1431025777408); 287 | }); 288 | 289 | afterEach(function() { 290 | this.clock.restore(); 291 | }); 292 | 293 | it("renders the specified yearMonth by default if no date is selected", function() { 294 | $scope.defaultDate = '2014-11-10'; 295 | compile(); 296 | 297 | expect($('.pickadate-centered-heading')).to.have.text('November 2014'); 298 | }); 299 | 300 | it("renders the specified yearMonth by default even if a date is selected", function() { 301 | $scope.defaultDate = '2014-11-10'; 302 | $scope.date = '2014-03-01'; 303 | compile(); 304 | 305 | expect($('.pickadate-centered-heading')).to.have.text('November 2014'); 306 | }); 307 | 308 | it("renders the current month if no date is selected and no default date is specified ", function() { 309 | compile(); 310 | 311 | expect($('.pickadate-centered-heading')).to.have.text('May 2015'); 312 | }); 313 | 314 | it("renders the selected date month if no default date is specified", function() { 315 | $scope.date = '2014-08-03'; 316 | compile(); 317 | 318 | expect($('.pickadate-centered-heading')).to.have.text('August 2014'); 319 | }); 320 | 321 | }); 322 | 323 | describe('Translations', function() { 324 | 325 | it('uses the default translations if not translations are specified', function() { 326 | compile(); 327 | expect($('.pickadate-prev')).to.have.text('prev'); 328 | expect($('.pickadate-next')).to.have.text('next'); 329 | }); 330 | 331 | it('uses the translations previously set in the pickadateI18nProvider', function() { 332 | pickadateI18nProvider.translations = { 333 | prev: 'ant', 334 | next: 'sig' 335 | }; 336 | compile(); 337 | expect($('.pickadate-prev')).to.have.text('ant'); 338 | expect($('.pickadate-next')).to.have.text('sig'); 339 | }); 340 | 341 | it('accepts valid html translations', function() { 342 | pickadateI18nProvider.translations = { 343 | prev: ' ant', 344 | next: 'sig ' 345 | }; 346 | compile(); 347 | expect($('.pickadate-prev')).to.have.html(' ant'); 348 | expect($('.pickadate-next')).to.have.html('sig '); 349 | }); 350 | 351 | }); 352 | 353 | describe('Using only required scope properties', function() { 354 | 355 | it("doesn't throw an error if only the required scope properties are being binded", function() { 356 | expect(function(){ 357 | compile('
'); 358 | }).not.to.throw(); 359 | 360 | }); 361 | 362 | }); 363 | 364 | describe('Date formats', function() { 365 | 366 | beforeEach(function() { 367 | this.clock = sinon.useFakeTimers(1431025777408); 368 | }); 369 | 370 | afterEach(function() { 371 | this.clock.restore(); 372 | }); 373 | 374 | var compileFormat = function(format) { 375 | compile('
'); 376 | }; 377 | 378 | it("sets the date in the right format after selecting it", function() { 379 | compileFormat('dd/MM/yyyy'); 380 | browserTrigger($('.pickadate-enabled:first'), 'click'); 381 | expect($scope.date).to.equal('01/05/2015'); 382 | }); 383 | 384 | it("takes the initial date in the right format", function() { 385 | $scope.date = '20/02/2012'; 386 | compileFormat('dd/mm/yyyy'); 387 | 388 | expect($scope.date).to.equal('20/02/2012'); 389 | expect($('.pickadate-centered-heading')).to.have.text('February 2012'); 390 | expect($('li:contains(20)')).to.have.class('pickadate-active'); 391 | }); 392 | 393 | }); 394 | 395 | describe('Multiple dates', function() { 396 | 397 | beforeEach(function() { 398 | this.clock = sinon.useFakeTimers(1431025777408); 399 | compile('
'); 400 | }); 401 | 402 | afterEach(function() { 403 | this.clock.restore(); 404 | }); 405 | 406 | it('sets the current ngModel to an empty array if its undefined', function() { 407 | expect($scope.date).to.be.empty; 408 | }); 409 | 410 | it('adds the selected date to the ngModel array', function() { 411 | browserTrigger($('.pickadate-enabled:contains(20)'), 'click'); 412 | browserTrigger($('.pickadate-enabled:contains(22)'), 'click'); 413 | expect($scope.date).to.deep.equal(['2015-05-20', '2015-05-22']); 414 | }); 415 | 416 | it('removes the selected date of the ngModel array if it was previously selected', function() { 417 | browserTrigger($('.pickadate-enabled:contains(7)'), 'click'); 418 | expect($scope.date).to.deep.equal(['2015-05-07']); 419 | 420 | browserTrigger($('.pickadate-enabled:contains(7)'), 'click'); 421 | expect($scope.date).to.be.empty; 422 | }); 423 | 424 | describe('Rendering', function() { 425 | 426 | it('renders the multiple dates', function() { 427 | $scope.date = ['2015-05-07', '2015-05-10', '2015-05-13']; 428 | $scope.$digest(); 429 | 430 | expect($('.pickadate-active').get()).to.have.length(3); 431 | expect($('.pickadate-active:eq(0)')).to.have.text('7'); 432 | expect($('.pickadate-active:eq(1)')).to.have.text('10'); 433 | expect($('.pickadate-active:eq(2)')).to.have.text('13'); 434 | }); 435 | 436 | }); 437 | 438 | describe('Disabled dates', function() { 439 | 440 | it("removes the disabled dates from the initial date array", function() { 441 | $scope.date = ['2015-05-07', '2015-05-10', '2015-05-13']; 442 | $scope.disabledDates = ['2015-05-10']; 443 | $scope.$digest(); 444 | 445 | expect($('.pickadate-active').get()).to.have.length(2); 446 | expect($('.pickadate-active:eq(0)')).to.have.text('7'); 447 | expect($('.pickadate-active:eq(1)')).to.have.text('13'); 448 | }); 449 | 450 | it("removes the disabled date if the ngModel is updated and contains disabled dates", function() { 451 | $scope.disabledDates = ['2015-05-10', '2015-05-13']; 452 | $scope.$digest(); 453 | 454 | $scope.date = ['2015-05-07', '2015-05-10', '2015-05-13']; 455 | $scope.$digest(); 456 | 457 | expect($('.pickadate-active').get()).to.have.length(1); 458 | expect($('.pickadate-active:eq(0)')).to.have.text('7'); 459 | }); 460 | 461 | }); 462 | 463 | }); 464 | 465 | describe('When used as a modal', function() { 466 | 467 | var inputHtml = '
' + 468 | '' + 469 | '
', 470 | input, form; 471 | 472 | beforeEach(function() { 473 | $scope.date = '2014-05-17'; 474 | $scope.minDate = '2014-01-01'; 475 | compile(inputHtml); 476 | form = element; 477 | input = $('input'); 478 | element = $('.pickadate'); 479 | }); 480 | 481 | it('adds the pickadate-modal class', function() { 482 | expect(element).to.have.class('pickadate-modal'); 483 | }); 484 | 485 | it('renders the datepicker already hidden', function() { 486 | expect(element).to.have.class('ng-hide'); 487 | }); 488 | 489 | it('displays the datepicker when the input is focused', function() { 490 | browserTrigger(input, 'focus'); 491 | expect(element).not.to.have.class('ng-hide'); 492 | }); 493 | 494 | it('hides the datepicker when the user clicks outside the datepicker', function() { 495 | expect(element).to.have.class('ng-hide'); 496 | 497 | browserTrigger(input, 'focus'); 498 | expect(element).not.to.have.class('ng-hide'); 499 | 500 | browserTrigger(document.body, 'click'); 501 | expect(element).to.have.class('ng-hide'); 502 | }); 503 | 504 | it("doesn't hide the datepicker if the calendar is clicked", function() { 505 | expect(element).to.have.class('ng-hide'); 506 | 507 | browserTrigger(input, 'focus'); 508 | expect(element).not.to.have.class('ng-hide'); 509 | 510 | browserTrigger($('.pickadate-centered-heading'), 'click'); 511 | expect(element).not.to.have.class('ng-hide'); 512 | }); 513 | 514 | it("hides the datepicker if a date is selected", function() { 515 | expect(element).to.have.class('ng-hide'); 516 | 517 | browserTrigger(input, 'focus'); 518 | expect(element).not.to.have.class('ng-hide'); 519 | 520 | browserTrigger($('.pickadate-enabled:first'), 'click'); 521 | expect(element).to.have.class('ng-hide'); 522 | }); 523 | 524 | it('sets the input value with the ng-model value', function() { 525 | expect(input).to.have.value('2014-05-17'); 526 | 527 | browserTrigger(input, 'focus'); 528 | browserTrigger($('.pickadate-enabled:first'), 'click'); 529 | 530 | expect(input).to.have.value('2014-05-01'); 531 | 532 | browserTrigger(input, 'focus'); 533 | browserTrigger($('.pickadate-enabled:last'), 'click'); 534 | 535 | expect(input).to.have.value('2014-05-31'); 536 | }); 537 | 538 | describe('and the input value is manually changed', function() { 539 | 540 | it('updates the ng-model with the entered value', function() { 541 | expect($scope.date).to.eq('2014-05-17'); 542 | 543 | input.val('2015-02-20'); 544 | browserTrigger(input, 'change'); 545 | 546 | expect($scope.date).to.eq('2015-02-20'); 547 | }); 548 | 549 | it('sets the ng-model to undefined if the date is not in a valid range', function() { 550 | expect($scope.date).to.eq('2014-05-17'); 551 | expect($scope.dateForm.$error).not.to.have.property('date'); 552 | 553 | input.val('2012-02-20'); 554 | browserTrigger(input, 'change'); 555 | 556 | expect($scope.dateForm.$error).to.have.property('date'); 557 | }); 558 | 559 | it('sets the ng-model to undefined if the date is not valid', function() { 560 | expect($scope.date).to.eq('2014-05-17'); 561 | expect($scope.dateForm.$error).not.to.have.property('date'); 562 | 563 | input.val('2012-02-'); 564 | browserTrigger(input, 'change'); 565 | 566 | expect($scope.dateForm.$error).to.have.property('date'); 567 | }); 568 | 569 | }); 570 | 571 | describe('and the input has already a value', function() { 572 | 573 | function compileModal(value) { 574 | var html = 575 | '
' + 576 | '' + 577 | '
'; 578 | 579 | $scope.minDate = '2014-01-01'; 580 | compile(html); 581 | 582 | form = element; 583 | input = $('input'); 584 | element = $('.pickadate'); 585 | } 586 | 587 | it("sets the ng-model value as the element's value", function() { 588 | $scope.ngModelDate = '2014-05-10'; 589 | compileModal('2015-01-14'); 590 | 591 | expect($scope.ngModelDate).to.eq('2015-01-14'); 592 | }); 593 | 594 | it("sets the ng-model value as undefined if is not in a valid range", function() { 595 | compileModal('2011-01-14'); 596 | expect($scope.ngModelDate).to.be.undefined; 597 | }); 598 | 599 | }); 600 | 601 | }); 602 | 603 | }); 604 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------