├── logo.png ├── misc ├── art │ └── logo.idraw ├── demo │ ├── views │ │ ├── demo.html │ │ ├── article.html │ │ ├── subreddit.html │ │ ├── commenter.html │ │ └── reddit.html │ ├── js │ │ ├── service │ │ │ └── github.js │ │ ├── directive │ │ │ ├── typeahead.js │ │ │ └── commenter.js │ │ └── app.js │ ├── index.html │ └── css │ │ └── app.css ├── test │ ├── helpers.js │ └── angular-mocks.js ├── changelog.tpl.md └── validate-commit-msg.js ├── .gitignore ├── template └── comments │ ├── comments.html │ └── comment.html ├── .travis.yml ├── docs ├── css │ └── style.css └── nav.html ├── bower.json ├── package.json ├── karma.conf.js ├── README.md ├── CHANGELOG.md ├── src ├── test │ └── comments.spec.js └── comments.js └── Gruntfile.js /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caitp/ui-comments/HEAD/logo.png -------------------------------------------------------------------------------- /misc/art/logo.idraw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caitp/ui-comments/HEAD/misc/art/logo.idraw -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.html.js 2 | .DS_Store 3 | .grunt 4 | /node_modules 5 | /dist 6 | *-SNAPSHOT* 7 | /bower_components 8 | -------------------------------------------------------------------------------- /template/comments/comments.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | -------------------------------------------------------------------------------- /misc/demo/views/demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | 5 |

6 | 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 0.10 4 | before_script: 5 | - export DISPLAY=:99.0 6 | - sh -e /etc/init.d/xvfb start 7 | - npm install --silent -g grunt-cli 8 | - npm install --dev --silent 9 | 10 | script: grunt 11 | -------------------------------------------------------------------------------- /misc/demo/js/service/github.js: -------------------------------------------------------------------------------- 1 | function githubService($http) { 2 | var origin = 'http://api.github.com'; 3 | var fetchUsers = function(query) { 4 | return $http({ 5 | method: 'JSONP', 6 | url : origin + '/search/users?callback=JSON_CALLBACK', 7 | params: {q: query} 8 | }); 9 | }; 10 | 11 | return { 12 | fetchUsers: fetchUsers 13 | }; 14 | } 15 | 16 | -------------------------------------------------------------------------------- /misc/demo/views/article.html: -------------------------------------------------------------------------------- 1 | 9 | 10 | -------------------------------------------------------------------------------- /docs/css/style.css: -------------------------------------------------------------------------------- 1 | .bs-docs-social { 2 | margin-top: 1em; 3 | padding: 15px 0; 4 | text-align: center; 5 | background-color: rgba(245,245,245,0.3); 6 | border-top: 1px solid rgba(255,255,255,0.3); 7 | border-bottom: 1px solid rgba(221,221,221,0.3); 8 | } 9 | .bs-docs-social-buttons { 10 | position: absolute; 11 | top: 8px; 12 | margin-left: 0; 13 | margin-bottom: 0; 14 | padding-left: 0; 15 | list-style: none; 16 | } 17 | .bs-docs-social-buttons li { 18 | list-style: none; 19 | display: inline-block; 20 | line-height: 1; 21 | } 22 | -------------------------------------------------------------------------------- /docs/nav.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | Download (<%= pkg.version%>) 4 | 5 |
6 |
7 | 13 | 14 | -------------------------------------------------------------------------------- /misc/demo/js/directive/typeahead.js: -------------------------------------------------------------------------------- 1 | function typeheadDirective() { 2 | return { 3 | restrict: 'E', 4 | link: function(scope, elm, att) { 5 | scope.handleSelection = function(val) { 6 | scope.child.name = val; 7 | scope.selected = true; 8 | }; 9 | }, 10 | template: '' 14 | }; 15 | } 16 | 17 | -------------------------------------------------------------------------------- /misc/test/helpers.js: -------------------------------------------------------------------------------- 1 | // jasmine matcher for expecting an element to have a css class 2 | // https://github.com/angular/angular.js/blob/master/test/matchers.js 3 | beforeEach(function() { 4 | jasmine.addMatchers({ 5 | toHaveClass: function(cls) { 6 | return { 7 | compare: function(actual, expected) { 8 | return { 9 | message: function() { 10 | return "Expected '" + angular.mock.dump(actual) + "' to have class '" + expected + "'."; 11 | }, 12 | pass: angular.element(actual).hasClass(expected) 13 | }; 14 | } 15 | }; 16 | } 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /template/comments/comment.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | {{comment.name}} 5 | {{comment.name}} 6 | {{comment.name}} 7 | {{comment.date}} 8 |
9 |
10 |
11 |
12 |
13 |
-------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ui-comments", 3 | "version": "0.1.2", 4 | "homepage": "https://github.com/caitp/ui-comments", 5 | "authors": [ 6 | "Caitlin Potter " 7 | ], 8 | "description": "Nested, Reddit-style comments directives for AngularJS", 9 | "keywords": [ 10 | "comments", 11 | "reddit", 12 | "nested", 13 | "directive", 14 | "angularjs" 15 | ], 16 | "license": "MIT", 17 | "ignore": [ 18 | "**/.*", 19 | "node_modules", 20 | "bower_components", 21 | "test", 22 | "tests" 23 | ], 24 | "dependencies": { 25 | "angular": "~1.2.8", 26 | "angular-animate": "~1.2.8", 27 | "angular-route": "~1.2.8", 28 | "angular-sanitize": "~1.2.8", 29 | "momentjs": "~2.5.0", 30 | "jquery": "~1.10.1", 31 | "bootstrap": "~3.0.3", 32 | "font-awesome": "~4.0.3" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /misc/demo/views/subreddit.html: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 |
8 |

 {{article.score}}

9 | {{article.author}} 10 | 11 | {{article.domain + ' (' + article.title + ')'}} 12 | 13 | 14 |

{{article.title}}

15 |
16 | 17 |
18 |
-------------------------------------------------------------------------------- /misc/changelog.tpl.md: -------------------------------------------------------------------------------- 1 | # <%= version%> (<%= today%>) 2 | <% if (_(changelog.feat).size() > 0) { %> 3 | ## Features 4 | <% _(changelog.feat).keys().sort().forEach(function(scope) { %> 5 | - **<%= scope%>:** <% changelog.feat[scope].forEach(function(change) { %> 6 | - <%= change.msg%> (<%= helpers.commitLink(change.sha1) %>) <% }); %><% }); %> <% } %> 7 | <% if (_(changelog.fix).size() > 0) { %> 8 | ## Bug Fixes 9 | <% _(changelog.fix).keys().sort().forEach(function(scope) { %> 10 | - **<%= scope%>:** <% changelog.fix[scope].forEach(function(change) { %> 11 | - <%= change.msg%> (<%= helpers.commitLink(change.sha1) %>) <% }); %><% }); %> <% } %> 12 | <% if (_(changelog.breaking).size() > 0) { %> 13 | ## Breaking Changes 14 | <% _(changelog.breaking).keys().sort().forEach(function(scope) { %> 15 | - **<%= scope%>:** <% changelog.breaking[scope].forEach(function(change) { %> 16 | <%= change.msg%><% }); %><% }); %> <% } %> 17 | -------------------------------------------------------------------------------- /misc/demo/views/commenter.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 6 | 7 |
8 |
9 | 11 |
12 | 13 |
14 | {{ btnText }} 16 |
17 | -------------------------------------------------------------------------------- /misc/demo/views/reddit.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | r/ 5 | 6 | 7 | 8 |
9 |
10 | 13 |
14 | 15 |

 {{sub.subscribers}}

16 |

 SFW? {{sub.sfw ? 'yes' : 'no'}}

17 |

{{sub.name}}  

{{sub.short_description}}

18 |
19 |
-------------------------------------------------------------------------------- /misc/demo/js/directive/commenter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Ariel Mashraki 3 | */ 4 | 5 | function commenterDirective($timeout, githubService) { 6 | return { 7 | restrict:'E', 8 | templateUrl: 'views/commenter.html', 9 | link: function(scope, elm, attr) { 10 | var action, timeout; 11 | scope.toggled = scope.$eval(attr.toggle) || false; 12 | scope.btnText = 'add comment'; 13 | 14 | scope.toggle = function() { 15 | scope.toggled = !scope.toggled; 16 | scope.btnText = (scope.toggled) ? 'add comment' : 'close'; 17 | scope.child = {}; 18 | }; 19 | 20 | attr.$observe('action', function(value) { 21 | action = scope.$eval(value); 22 | }); 23 | 24 | scope.action = function(val) { 25 | action(val); 26 | scope.toggle(); 27 | }; 28 | 29 | scope.$watch('child.name', function(newUserName) { 30 | if(newUserName) { 31 | if(timeout) $timeout.cancel(timeout); 32 | timeout = $timeout(function() { 33 | githubService.fetchUsers(newUserName) 34 | .success(function(res) { 35 | scope.items = res.data['items'] || []; 36 | }); 37 | }, 300) 38 | } 39 | }); 40 | } 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ui-comments", 3 | "version": "0.1.3-SNAPSHOT", 4 | "description": "Nested, Reddit-style comments directives for AngularJS", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "Caitlin Potter ", 10 | "license": "MIT", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/caitp/ui-comments.git" 14 | }, 15 | "devDependencies": { 16 | "grunt": "~0.4.4", 17 | "karma": "~0.12.0", 18 | "grunt-ngdocs-caitp": "~0.1.8", 19 | "grunt-conventional-changelog": "~1.2.2", 20 | "grunt-contrib-concat": "~0.5.0", 21 | "grunt-contrib-copy": "~0.8.0", 22 | "grunt-contrib-uglify": "~0.9.1", 23 | "grunt-contrib-watch": "~0.6.1", 24 | "grunt-contrib-jshint": "~0.11.2", 25 | "grunt-html2js": "~0.3.2", 26 | "grunt-karma": "~0.11.2", 27 | "node-markdown": "~0.1.1", 28 | "grunt-ngmin": "0.0.3", 29 | "semver": "~4.3.3", 30 | "shelljs": "~0.5.0", 31 | "grunt-gh-pages": "~0.10.0", 32 | "grunt-contrib-connect": "~0.10.1", 33 | "grunt-contrib-clean": "~0.6.0", 34 | "bower": "~1.4.1", 35 | "karma-jasmine": "~0.3.3", 36 | "karma-firefox-launcher": "~0.1.3", 37 | "karma-phantomjs-launcher": "~0.2.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | config.set({ 3 | // base path, that will be used to resolve files and exclude 4 | basePath: '.', 5 | 6 | frameworks: ['jasmine'], 7 | 8 | // list of files / patterns to load in the browser 9 | files: [ 10 | 'misc/test/jquery.js', 11 | 'misc/test/angular.js', 12 | 'misc/test/angular-mocks.js', 13 | 'misc/test/helpers.js', 14 | 'src/**/*.js', 15 | 'template/**/*.js' 16 | ], 17 | 18 | // list of files to exclude 19 | exclude: [], 20 | 21 | // Start these browsers, currently available: 22 | // - Chrome 23 | // - ChromeCanary 24 | // - Firefox 25 | // - Opera 26 | // - Safari 27 | // - PhantomJS 28 | browsers: [ 29 | 'Chrome' 30 | ], 31 | 32 | // test results reporter to use 33 | // possible values: dots || progress 34 | reporters: 'progress', 35 | 36 | // web server port 37 | port: 9018, 38 | 39 | // cli runner port 40 | runnerPort: 9100, 41 | 42 | // enable / disable colors in the output (reporters and logs) 43 | colors: true, 44 | 45 | // level of logging 46 | // possible values: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG 47 | logLevel: config.LOG_INFO, 48 | 49 | // enable / disable watching file and executing tests whenever any file changes 50 | autoWatch: false, 51 | 52 | // Continuous Integration mode 53 | // if true, it capture browsers, run tests and exit 54 | singleRun: false 55 | }); 56 | } -------------------------------------------------------------------------------- /misc/validate-commit-msg.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Git COMMIT-MSG hook for validating commit message 5 | * See https://docs.google.com/document/d/1rk04jEuGfk9kYzfqCuOlPTSJw3hEDZJTBN5E5f1SALo/edit 6 | * 7 | * Installation: 8 | * >> cd 9 | * >> ln -s validate-commit-msg.js .git/hooks/commit-msg 10 | */ 11 | var fs = require('fs'); 12 | var util = require('util'); 13 | 14 | 15 | var MAX_LENGTH = 70; 16 | var PATTERN = /^(?:fixup!\s*)?(\w*)(\(((?:\w|[$,.-])+)\))?\: (.*)$/; 17 | var IGNORED = /^WIP\:/; 18 | var TYPES = { 19 | chore: true, 20 | demo: true, 21 | docs: true, 22 | feat: true, 23 | fix: true, 24 | refactor: true, 25 | revert: true, 26 | style: true, 27 | test: true 28 | }; 29 | 30 | 31 | var error = function() { 32 | // gitx does not display it 33 | // http://gitx.lighthouseapp.com/projects/17830/tickets/294-feature-display-hook-error-message-when-hook-fails 34 | // https://groups.google.com/group/gitx/browse_thread/thread/a03bcab60844b812 35 | console.error('INVALID COMMIT MSG: ' + util.format.apply(null, arguments)); 36 | }; 37 | 38 | 39 | var validateMessage = function(message) { 40 | var isValid = true; 41 | 42 | if (IGNORED.test(message)) { 43 | console.log('Commit message validation ignored.'); 44 | return true; 45 | } 46 | 47 | if (message.length > MAX_LENGTH) { 48 | error('is longer than %d characters !', MAX_LENGTH); 49 | isValid = false; 50 | } 51 | 52 | var match = PATTERN.exec(message); 53 | 54 | if (!match) { 55 | error('does not match "(): " ! was: "' + message + '"\nNote: must be only letters.'); 56 | return false; 57 | } 58 | 59 | var type = match[1]; 60 | var scope = match[3]; 61 | var subject = match[4]; 62 | 63 | if (!TYPES.hasOwnProperty(type)) { 64 | error('"%s" is not allowed type !', type); 65 | return false; 66 | } 67 | 68 | // Some more ideas, do want anything like this ? 69 | // - allow only specific scopes (eg. fix(docs) should not be allowed ? 70 | // - auto correct the type to lower case ? 71 | // - auto correct first letter of the subject to lower case ? 72 | // - auto add empty line after subject ? 73 | // - auto remove empty () ? 74 | // - auto correct typos in type ? 75 | // - store incorrect messages, so that we can learn 76 | 77 | return isValid; 78 | }; 79 | 80 | 81 | var firstLineFromBuffer = function(buffer) { 82 | return buffer.toString().split('\n').shift(); 83 | }; 84 | 85 | 86 | 87 | // publish for testing 88 | exports.validateMessage = validateMessage; 89 | 90 | // hacky start if not run by jasmine :-D 91 | if (process.argv.join('').indexOf('jasmine-node') === -1) { 92 | var commitMsgFile = process.argv[2]; 93 | var incorrectLogFile = commitMsgFile.replace('COMMIT_EDITMSG', 'logs/incorrect-commit-msgs'); 94 | 95 | fs.readFile(commitMsgFile, function(err, buffer) { 96 | var msg = firstLineFromBuffer(buffer); 97 | 98 | if (!validateMessage(msg)) { 99 | fs.appendFile(incorrectLogFile, msg + '\n', function() { 100 | process.exit(1); 101 | }); 102 | } else { 103 | process.exit(0); 104 | } 105 | }); 106 | } 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## UI-Comments [![Build Status](https://travis-ci.org/caitp/ui-comments.svg?branch=master)](https://travis-ci.org/caitp/ui-comments) [![devDependency Status](https://david-dm.org/caitp/ui-comments/dev-status.svg)](https://david-dm.org/caitp/ui-comments#info=devDependencies) 2 | 3 | ### Nested, Reddit-style comments directives for AngularJS 4 | 5 | ##### [Demo](http://caitp.github.io/ui-comments/) | [Docs](http://caitp.github.io/ui-comments/docs) 6 | 7 | Comments are all over the internet these days. It's often not in anyone's best interest to read them, and yet sometimes they offer a certain whimsical charm and hilarity, or otherwise engage you in pop-culture in ways that you'd otherwise miss. 8 | 9 | Angular directives can make engineering comment systems very simple, and `$http`, `$resource`, or similar tools can vastly simplify the population of comment data. 10 | 11 | The intention of this library is to serve as a simple helper, and perhaps as an example, for a reddit-style comment system, built on two very simple AngularJS directives. 12 | 13 | ### Development 14 | 15 | Building the code is a super-simple exercise: 16 | 17 | ```bash 18 | $ NODE_ENV=development npm install 19 | $ grunt 20 | ``` 21 | 22 | The above commands will install development dependencies for the library, and run the build and test process (on a single browser, by default). 23 | 24 | If desired, it is possible to manually run tests with specific browsers: 25 | 26 | - 1) Start the test server: 27 | 28 | ```bash 29 | $ karma start --browsers PhantomJS [--browsers Chrome [--browsers Firefox]] 30 | ``` 31 | 32 | - 2) After the server and desired browsers are started up, run the tests: 33 | 34 | ```bash 35 | $ karma run 36 | ``` 37 | 38 | - 3) Alternatively, you can utilize `--single-run` to start the server, execute tests and quit immediately: 39 | 40 | ```bash 41 | $ karma start [desired --browsers] --single-run 42 | ``` 43 | 44 | Hopefully everything is passing! 45 | 46 | Note that it is nice to have a global installation of `karma`, however if this is not available, one can use `./node_modules/grunt-karma/node_modules/karma/bin/karma` instead. That's somewhat more verbose, however! 47 | 48 | ### Contributing 49 | 50 | It would be super cool if this project is actually useful to people, and I know that for what it is currently, there are a lot of things that could be improved on, which I'm not totally sure how to do in a nice way. 51 | 52 | - Simpler template customization 53 | - Improved documentation 54 | - Service API for server communication 55 | - More robust testing 56 | 57 | So there are lots of things to do, and it would be just awesome if people could help improve them. But, even if people only ever use it as an example for generating nested AngularJS directives, that's fine too. I really just wrote this for fun, but I hope that people can get some use out of it as well. 58 | 59 | Then again, it works pretty well! 60 | 61 | ### License 62 | 63 | The MIT License (MIT) 64 | 65 | Copyright (c) 2013 Caitlin Potter & Contributors 66 | 67 | Permission is hereby granted, free of charge, to any person obtaining a copy 68 | of this software and associated documentation files (the "Software"), to deal 69 | in the Software without restriction, including without limitation the rights 70 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 71 | copies of the Software, and to permit persons to whom the Software is 72 | furnished to do so, subject to the following conditions: 73 | 74 | The above copyright notice and this permission notice shall be included in 75 | all copies or substantial portions of the Software. 76 | 77 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 78 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 79 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 80 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 81 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 82 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 83 | THE SOFTWARE. 84 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ### v0.1.2 (2013-10-25) 3 | 4 | 5 | #### Bug Fixes 6 | 7 | * **travis:** install karma@v0.10.2 due to broken v0.10.3 ([dc995dc7](http://github.com/caitp/ui-comments/commit/dc995dc70fe0b09c1a8bb106e9bd2f092a998ca3)) 8 | 9 | 10 | ### v0.1.1 (2013-10-23) 11 | 12 | 13 | #### Bug Fixes 14 | 15 | * **events:** now emiting $scope events rather than DOM events ([7b711238](http://github.com/caitp/ui-comments/commit/7b7112382498d451de0d852a4625376e9942f2e8)) 16 | 17 | 18 | #### Breaking Changes 19 | 20 | * 21 | Rather than binding `$element.bind('filled.comments', fn)`, one must instead 22 | `$scope.$on('$filledNestedComments', fn)` and expect a jQuery/jqLite element, 23 | which is either the comment element itself (if there is no commentsTransclude 24 | in use), or the commentsTransclude element (or the comments collection which 25 | replaces it) if it is present. 26 | 27 | Similarly, `$element.bind('emptied.comments', fn)` must instead be written as 28 | `$scope.$on('$emptiedNestedComments', fn)`. The same parameter rules as above 29 | apply. 30 | 31 | The event names are not yet final, and will likely be broken again in the near 32 | future. 33 | ([7b711238](http://github.com/caitp/ui-comments/commit/7b7112382498d451de0d852a4625376e9942f2e8)) 34 | 35 | 36 | ## v0.1.0 (2013-10-22) 37 | 38 | 39 | #### Features 40 | 41 | * **comments:** directive to define insert nested comments in template ([4c796ccc](http://github.com/caitp/ui-comments/commit/4c796ccca6ebaf7484ab7034a40138756d183cb5)) 42 | 43 | 44 | ### v0.0.10 (2013-10-22) 45 | 46 | 47 | #### Bug Fixes 48 | 49 | * **style:** remove tab spacing from source files ([2066c22f](http://github.com/caitp/ui-comments/commit/2066c22f3baf107fabdf7c5bdb244e5c80c74799)) 50 | 51 | 52 | ### v0.0.9 (2013-10-16) 53 | 54 | 55 | ### v0.0.8 (2013-09-27) 56 | 57 | 58 | ### v0.0.7 (2013-09-27) 59 | 60 | 61 | #### Bug Fixes 62 | 63 | * **release:** 64 | * gh-pages add, don't delete old content. ([2ba46417](http://github.com/caitp/ui-comments/commit/2ba464175bea2993347b3c9bb7e514d2b6117dc5)) 65 | * gh-pages before SNAPSHOT, and ignore SNAPSHOT ([49dca37b](http://github.com/caitp/ui-comments/commit/49dca37b7fd2d82ada4efc70ac63f034b7f1e21f)) 66 | 67 | 68 | #### Features 69 | 70 | * **docs:** Setup GA on API docs and demo ([0a125574](http://github.com/caitp/ui-comments/commit/0a12557419cee244f33722664afd1fe2dc95882a)) 71 | 72 | 73 | ### v0.0.6 (2013-09-26) 74 | 75 | 76 | #### Bug Fixes 77 | 78 | * **release:** 79 | * Cleanup test build before release ([1305a435](http://github.com/caitp/ui-comments/commit/1305a435f702398cc9836e9fa5c5322663ea942e)) 80 | * Generate gh-pages for correct version. ([01805f25](http://github.com/caitp/ui-comments/commit/01805f2516b5c0d62716b582f2cf02f6e9b8b395)) 81 | * gh-pages before SNAPSHOT, and ignore SNAPSHOT ([54ae7b2d](http://github.com/caitp/ui-comments/commit/54ae7b2dae85be06c014b2a0040c06e1edaac820)) 82 | 83 | 84 | ### v0.0.5 (2013-09-26) 85 | 86 | 87 | #### Bug Fixes 88 | 89 | * **release:** Generate gh-pages branch before setting SNAPSHOT version ([52cb6549](http://github.com/caitp/ui-comments/commit/52cb65491d84a70104f25dcff7bfe0f74c2d34e2)) 90 | 91 | 92 | ### v0.0.4 (2013-09-26) 93 | 94 | 95 | #### Bug Fixes 96 | 97 | * **TravisCI:** Use `npm --silent` instead of `npm --quiet` ([bcd30e2a](http://github.com/caitp/ui-comments/commit/bcd30e2ac21a79ce3fe92b759cac09b9a8c75dc6)) 98 | * **demo:** Fixup demo URL to API docs ([ac467814](http://github.com/caitp/ui-comments/commit/ac467814e982354e3a93c17d9b2acba63b5e8dda)) 99 | * **ngdocs:** Temporarily using fork which preprocesses navTemplate ([f0812f45](http://github.com/caitp/ui-comments/commit/f0812f45a529513fcbda9fc7ca95e525cca1e785)) 100 | 101 | 102 | #### Features 103 | 104 | * **tools:** Auto-publishing of gh-pages branch. ([c1508fcb](http://github.com/caitp/ui-comments/commit/c1508fcbf83d749e28be8915f56ac9a6af4640a0)) 105 | 106 | 107 | ### v0.0.3 (2013-09-26) 108 | 109 | 110 | #### Bug Fixes 111 | 112 | * **comments:** Improve commentsConfig.set() perf. ([7bfc109d](http://github.com/caitp/ui-comments/commit/7bfc109d489a965e18c77907438a2419d79952fa)) 113 | * **dependencies:** Update semver and shelljs devDependencies ([ba71f839](http://github.com/caitp/ui-comments/commit/ba71f83993997967d1a5a76cf56abd5d8a405506)) 114 | * **event:** Emit event only after compilation is completed ([c5c23b6d](http://github.com/caitp/ui-comments/commit/c5c23b6dd3d4c8765f26567eee547106a7f1653f)) 115 | * **release:** Prefix tag-names with 'v', for semver-ness ([29f20bc6](http://github.com/caitp/ui-comments/commit/29f20bc637ed78652793f6c2a0ef2e5eaae9f0cf)) 116 | * **template:** ngHref and ngSrc directives fixed ([a8835770](http://github.com/caitp/ui-comments/commit/a8835770b8da14b7a83a452647f3f4f21e4dd1c2)) 117 | 118 | 119 | #### Features 120 | 121 | * **comments:** Instantiate configured controller for comments ([07390982](http://github.com/caitp/ui-comments/commit/07390982172e8ea1a5a956b8c3362aa45bca2d7f)) 122 | * **events:** Comment now emit when children truthiness changes ([13f702e5](http://github.com/caitp/ui-comments/commit/13f702e56273f40d5ea671ffa5a37ff952850150)) 123 | 124 | -------------------------------------------------------------------------------- /misc/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | UI-Comments for AngularJS 5 | 6 | 7 | 8 | 31 | 32 | 37 | 38 | 39 |
40 | 70 |
71 |
72 |

UI Comments

73 | 74 |

Nested, Reddit-style comments directives for AngularJS

75 |

Customizable comment templates, customizable comment controllers, and built-in support for nesting and sorting comments. It's just lovely stuff, innit.

76 |

77 | 79 | Code on Github 80 | 83 | Download (<%= pkg.version%>) 84 | API Documentation 87 |

88 |
89 |
90 |
91 |
92 | 100 |
101 |
102 |
103 |
104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /misc/demo/css/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top: 50px; 3 | opacity: 1; 4 | -webkit-transition: opacity 1s ease; 5 | -moz-transition: opacity 1s ease; 6 | transition: opacity 1s; 7 | padding-bottom: 10em; 8 | } 9 | 10 | .comment { 11 | margin: .25em; 12 | } 13 | 14 | .comment > .comments { 15 | margin-top: 1em; 16 | margin-left: 2em; 17 | margin-bottom: 1em; 18 | } 19 | 20 | .comment-header { 21 | vertical-align: middle; 22 | line-height: 2em; 23 | } 24 | 25 | .comment-header > h4 { 26 | display: inline; 27 | } 28 | 29 | .comment > .page-header { 30 | margin-top: 0; 31 | padding-top: 0; 32 | } 33 | 34 | .jumbotron { 35 | font-family: Arial, Helvetica, sans-serif; 36 | position: relative; 37 | padding: 40px 0; 38 | color: #fff; 39 | text-align: center; 40 | text-shadow: 0 1px 3px rgba(0,0,0,.4), 0 0 30px rgba(0,0,0,.075); 41 | background: #020031; 42 | background: -moz-linear-gradient(45deg, #020031 0%, #6d3353 100%); 43 | background: -webkit-gradient(linear, left bottom, right top, color-stop(0%,#020031), color-stop(100%,#6d3353)); 44 | background: -webkit-linear-gradient(45deg, #020031 0%,#6d3353 100%); 45 | background: -o-linear-gradient(45deg, #020031 0%,#6d3353 100%); 46 | background: -ms-linear-gradient(45deg, #020031 0%,#6d3353 100%); 47 | background: linear-gradient(45deg, #020031 0%,#6d3353 100%); 48 | filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#020031', endColorstr='#6d3353',GradientType=1 ); 49 | -webkit-box-shadow: inset 0 3px 7px rgba(0,0,0,.2), inset 0 -3px 7px rgba(0,0,0,.2); 50 | -moz-box-shadow: inset 0 3px 7px rgba(0,0,0,.2), inset 0 -3px 7px rgba(0,0,0,.2); 51 | box-shadow: inset 0 3px 7px rgba(0,0,0,.2), inset 0 -3px 7px rgba(0,0,0,.2); 52 | border-radius: 0; 53 | -moz-border-radius: 0; 54 | -webkit-border-radius: 0; 55 | -o-border-radius: 0; 56 | } 57 | .jumbotron .btn, .pagination-centered .btn { 58 | /*float: none;*/ 59 | font-weight: normal; 60 | } 61 | .jumbotron p { 62 | margin: 1em 0; 63 | } 64 | .bs-docs-social { 65 | margin-top: 1em; 66 | padding: 15px 0; 67 | text-align: center; 68 | background-color: rgba(245,245,245,0.3); 69 | border-top: 1px solid rgba(255,255,255,0.3); 70 | border-bottom: 1px solid rgba(221,221,221,0.3); 71 | } 72 | 73 | .nav-ghbtn { 74 | padding-top: 16px; 75 | } 76 | 77 | a>.text { 78 | display: none; 79 | } 80 | 81 | @media (max-width: 767px) { 82 | .in a>.text, .collapsing a>.text { 83 | display: inline; 84 | font-weight: light; 85 | font-size: 16px; 86 | } 87 | } 88 | 89 | .clear-square { 90 | vertical-align: sub; 91 | border: none; 92 | background: none; 93 | outline: none; 94 | min-width: 36px; 95 | min-height: 34px; 96 | } 97 | 98 | .subreddit { 99 | display: table; 100 | width: 100%; 101 | border-top: 1px solid #eee; 102 | margin-top: .5em; 103 | padding-top: .5em; 104 | min-width: 720px; 105 | overflow: scroll; 106 | } 107 | 108 | .subreddit > * { 109 | height: 92px; 110 | } 111 | 112 | .subreddits: { 113 | display: table; 114 | } 115 | .subreddits:hover { 116 | text-decoration: none; 117 | } 118 | .subreddits:focus { 119 | text-decoration: none; 120 | } 121 | .subreddits > * { 122 | height: 50px; 123 | display: table-cell; 124 | vertical-align: middle; 125 | text-align: left; 126 | word-wrap:break-word; 127 | width: 100px; 128 | margin: 0; 129 | padding: 0; 130 | margin-right: 1em; 131 | } 132 | 133 | .subreddits > .title { 134 | width: 160px!important; 135 | } 136 | 137 | .subreddits > h4 { 138 | width: auto; 139 | margin-right: 0; 140 | } 141 | 142 | .subreddit:first-child { 143 | border: none; 144 | margin-top: 0; 145 | padding-top: 0; 146 | } 147 | 148 | .subreddit > .score, .subreddit > .author { 149 | display: table-cell; 150 | vertical-align: middle; 151 | text-align: left; 152 | } 153 | 154 | .subreddit > .author { 155 | margin: 0; 156 | padding: 0; 157 | } 158 | 159 | .subreddit .score { 160 | width: 64px; 161 | } 162 | .subreddit .author { 163 | width: 160px; 164 | } 165 | 166 | .subreddit .thumbnail { 167 | width: 92px; 168 | margin: 0; 169 | padding: 0; 170 | border: 0; 171 | } 172 | 173 | .subreddit .title { 174 | display: table-cell; 175 | vertical-align: middle; 176 | text-align: left; 177 | word-wrap:break-word; 178 | } 179 | 180 | .subreddit .title h3, .subreddit .title p { 181 | margin-left: .5em; 182 | } 183 | 184 | .subreddit .img { 185 | width: 92px; 186 | } 187 | 188 | .view-container { 189 | position: relative; 190 | } 191 | 192 | .slider { 193 | -webkit-transform: translateX(0); 194 | -moz-transform: translateX(0); 195 | -o-transform: translateX(0); 196 | -ms-transform: translateX(0); 197 | transform: translateX(0); 198 | } 199 | 200 | .slider.ng-enter, .slider.ng-leave { 201 | position: absolute; 202 | top: 0; 203 | left: 0; 204 | -webkit-transition: all cubic-bezier(0.455, 0.03, 0.515, 0.955) 0.5s; 205 | -moz-transition: all cubic-bezier(0.455, 0.03, 0.515, 0.955) 0.5s; 206 | -o-transition: all cubic-bezier(0.455, 0.03, 0.515, 0.955) 0.5s; 207 | -ms-transition: all cubic-bezier(0.455, 0.03, 0.515, 0.955) 0.5s; 208 | transition: all cubic-bezier(0.455, 0.03, 0.515, 0.955) 0.5s; 209 | -webkit-transform: translateZ(0); 210 | -moz-transform: translateZ(0); 211 | -o-transform: translateZ(0); 212 | -ms-transform: translateZ(0); 213 | transform: translateZ(0); 214 | } 215 | .slider.ng-enter { 216 | -webkit-transform: translateX(200%)!important; 217 | -moz-transform: translateX(200%)!important; 218 | -o-transform: translateX(200%)!important; 219 | -ms-transform: translateX(200%)!important; 220 | transform: translateX(200%)!important; 221 | } 222 | .slider.ng-enter-active { 223 | -webkit-transform: translateX(0)!important; 224 | -moz-transform: translateX(0)!important; 225 | -o-transform: translateX(0)!important; 226 | -ms-transform: translateX(0)!important; 227 | transform: translateX(0)!important; 228 | } 229 | 230 | .slider.ng-leave-active { 231 | -webkit-transform: translateX(-200%)!important; 232 | -moz-transform: translateX(-200%)!important; 233 | -o-transform: translateX(-200%)!important; 234 | -ms-transform: translateX(-200%)!important; 235 | transform: translateX(-200%)!important; 236 | } 237 | 238 | .fader.ng-enter, .fader.ng-leave { 239 | -webkit-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; 240 | -moz-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; 241 | -o-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; 242 | -ms-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; 243 | transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; 244 | } 245 | 246 | .fader.ng-enter, 247 | .fader.ng-leave.ng-leave-active { 248 | opacity: 0; 249 | } 250 | 251 | .fader.ng-leave, 252 | .fader.ng-enter.ng-enter-active { 253 | opacity: 1; 254 | } 255 | 256 | iframe { 257 | width: 80px; 258 | height: 50px; 259 | background-color: transparent; 260 | border: none; 261 | } 262 | 263 | .active > a { 264 | pointer-events: none; 265 | cursor: default; 266 | } 267 | 268 | .commenter-container { 269 | border-top: 1px solid #ddd; 270 | } 271 | 272 | .form-inline .form-group { 273 | position: relative; 274 | display: block!important; 275 | margin-bottom: 15px !important; 276 | } 277 | 278 | .typeahead ul { 279 | width:100%; 280 | position: absolute; 281 | overflow: auto; 282 | max-height: 300px; 283 | background: #fff; 284 | border: 1px solid #ddd; 285 | border-top: 0px; 286 | z-index: 999; 287 | } 288 | 289 | .pointer { 290 | cursor: pointer; 291 | } 292 | 293 | .form-control.ng-invalid.ng-dirty { 294 | border-color: red; 295 | } 296 | 297 | .avatar_img { 298 | width: 25px; 299 | height: 25px; 300 | float: left; 301 | margin: 7px; 302 | } 303 | 304 | .nav > li > a:hover, .nav > li > a:focus { 305 | text-decoration: none; 306 | background-color: rgba(173, 173, 173, 0.4)!important; 307 | } 308 | 309 | .add_comment .commenter-container { 310 | margin-top: 20px; 311 | border: 1px solid #C2C2C2; 312 | border-radius: 4px; 313 | background: #F0F0F0; 314 | } 315 | 316 | -------------------------------------------------------------------------------- /src/test/comments.spec.js: -------------------------------------------------------------------------------- 1 | describe('ui.comments', function() { 2 | var $scope, $document, $body, $compile, comments, firstComment, firstCtrl; 3 | beforeEach(function() { 4 | angular.module('testModule', [ 5 | 'ui.comments.directive', 6 | 'template/comments/comments.html', 7 | 'template/comments/comment.html' 8 | ]) 9 | .controller('TestCtrl1', function($scope, $element) { 10 | this.controllerName = "TestCtrl1"; 11 | this.$element = $element; 12 | this.$scope = $scope; 13 | }) 14 | .controller('TestCtrl2', function($scope, $element) { 15 | this.controllerName = "TestCtrl2"; 16 | this.$element = $element; 17 | this.$scope = $scope; 18 | }); 19 | angular.forEach([ 20 | 'testModule', 21 | 'ui.comments.directive', 22 | 'template/comments/comments.html', 23 | 'template/comments/comment.html', 24 | ], function(m) { 25 | module(m); 26 | }); 27 | 28 | inject(function(_$rootScope_, _$document_, _$compile_) { 29 | $scope = _$rootScope_; 30 | $document = _$document_; 31 | $compile = _$compile_; 32 | $body = $document.find('body'); 33 | }); 34 | firstComment = function() { 35 | return comments.find('.comment').first(); 36 | } 37 | firstCtrl = function() { 38 | return firstComment().controller('Comment'); 39 | } 40 | }); 41 | 42 | 43 | describe('DOM', function() { 44 | describe('top-level comments', function() { 45 | beforeEach(function() { 46 | $scope.comments = []; 47 | comments = $compile('')($scope); 48 | }); 49 | 50 | 51 | it('adds comment to DOM when comment model grows', function() { 52 | expect(comments.children().length).toBe(0); 53 | $scope.comments.push({}); 54 | $scope.$digest(); 55 | expect(comments.children().length).toBe(1); 56 | }); 57 | 58 | 59 | it('removes comment from DOM when comment model shrinks', function() { 60 | $scope.comments.push({}); 61 | $scope.$digest(); 62 | expect(comments.children().length).toBe(1); 63 | $scope.comments.pop(); 64 | $scope.$digest(); 65 | expect(comments.children().length).toBe(0); 66 | }); 67 | 68 | 69 | it('changes comment data when comment model changes', function() { 70 | $scope.comments.push({text: 'Test Comment'}); 71 | $scope.$digest(); 72 | expect(comments.find('.comment-body > div').text()).toBe("Test Comment"); 73 | $scope.comments[0].text = 'Changed Comment'; 74 | $scope.$digest(); 75 | expect(comments.find('.comment-body > div').text()).toBe("Changed Comment"); 76 | }); 77 | 78 | 79 | it('re-orders comments in DOM when comment model is re-ordered', function() { 80 | $scope.comments.push({text: '123'}); 81 | $scope.comments.push({text: 'ABC'}); 82 | $scope.$digest(); 83 | expect(comments.find('.comment-body > div').first().text()).toBe('123'); 84 | $scope.comments.reverse(); 85 | $scope.$digest(); 86 | expect(comments.find('.comment-body > div').first().text()).toBe('ABC'); 87 | }); 88 | }); 89 | 90 | 91 | describe('child comments', function() { 92 | beforeEach(function() { 93 | $scope.comments = [{ 94 | text: 'Comment level 1', 95 | children: [ 96 | { text: 'First child' }, 97 | { text: 'Second child' } 98 | ] 99 | }]; 100 | comments = $compile('')($scope); 101 | $scope.$digest(); 102 | }); 103 | 104 | 105 | it('adds comment to DOM when child comment model grows', function() { 106 | expect(comments.find('.child-comment').length).toBe(2); 107 | $scope.comments[0].children.push({text: 'Test'}); 108 | $scope.$digest(); 109 | expect(comments.find('.child-comment').length).toBe(3); 110 | }); 111 | 112 | 113 | it('removes comment from DOM when child comment model shrinks', function() { 114 | expect(comments.find('.child-comment').length).toBe(2); 115 | $scope.comments[0].children.pop(); 116 | $scope.$digest(); 117 | expect(comments.find('.child-comment').length).toBe(1); 118 | }); 119 | 120 | 121 | it('changes comment data when child comment model changes', function() { 122 | var first = comments.find('.child-comment > .comment-body > div').first(); 123 | expect(first.text()).toBe("First child"); 124 | $scope.comments[0].children[0].text = 'Changed Comment'; 125 | $scope.$digest(); 126 | expect(first.text()).toBe("Changed Comment"); 127 | }); 128 | 129 | 130 | it('re-orders comments in DOM when child comment model is re-ordered', function() { 131 | var children = comments.find('.child-comment'); 132 | expect(children.find('.comment-body > div').first().text()).toBe('First child'); 133 | $scope.comments[0].children.reverse(); 134 | $scope.$digest(); 135 | expect(children.find('.comment-body > div').first().text()).toBe('Second child'); 136 | }); 137 | }); 138 | 139 | 140 | describe('depth limit', function() { 141 | beforeEach(function() { 142 | $scope.comments = [{ 143 | text: 'Comment level 1', 144 | children: [ 145 | { text: 'First child' }, 146 | { text: 'Second child' } 147 | ] 148 | }]; 149 | $scope.depth = 1; 150 | }); 151 | 152 | 153 | it('prevents creation of child comments when exceeded', function() { 154 | comments = $compile('')($scope); 156 | $scope.$digest(); 157 | expect(comments.find('.comment').length).toBe(1); 158 | }); 159 | }) 160 | }); 161 | 162 | 163 | describe('events', function() { 164 | var config; 165 | beforeEach(inject(function(commentsConfig) { 166 | config = commentsConfig; 167 | commentsConfig.commentController = 'TestCtrl1'; 168 | })); 169 | it('fires `$filledNestedComments` when child comments become available', function() { 170 | $scope.comments = [{children: []}]; 171 | comments = $compile('')($scope); 172 | $scope.$digest(); 173 | var scope = firstCtrl().$scope, 174 | callback = jasmine.createSpy('commentsFilled'); 175 | scope.$on('$filledNestedComments', callback); 176 | $scope.comments[0].children = [{}]; 177 | $scope.$digest(); 178 | expect(callback).toHaveBeenCalled(); 179 | expect(callback.calls.mostRecent().args[0][0]). 180 | toBe(comments.find('.comments').first()[0]); 181 | expect(callback.calls.mostRecent().args[0].children().length).toBe(1); 182 | }); 183 | 184 | 185 | it('fires `$emptiedNestedComments` when child comments are no longer available', function() { 186 | $scope.comments = [{children: [{}]}]; 187 | comments = $compile('')($scope); 188 | $scope.$digest(); 189 | var scope = firstCtrl().$scope, 190 | callback = jasmine.createSpy('commentsEmptied'); 191 | scope.$on('$emptiedNestedComments', callback); 192 | $scope.comments[0].children = []; 193 | $scope.$digest(); 194 | expect(callback).toHaveBeenCalled(); 195 | expect(comments.find('.comment .comments-transclude').first().length).toBe(1); 196 | expect(callback.calls.mostRecent().args[0]). 197 | toHaveClass('comments-transclude'); 198 | expect(callback.calls.mostRecent().args[0].children().length). 199 | toBe(0); 200 | }); 201 | 202 | 203 | it('fires `$depthLimitComments` when the level of child comments exceeds the depth limit', function() { 204 | config.depthLimit = 1; 205 | $scope.comments = [{children: []}]; 206 | comments = $compile('')($scope); 207 | $scope.$digest(); 208 | var scope = firstCtrl().$scope, 209 | callback = jasmine.createSpy('depthLimitHit'); 210 | scope.$on('$depthLimitComments', callback); 211 | $scope.comments[0].children = [{}]; 212 | $scope.$digest(); 213 | expect(callback).toHaveBeenCalled(); 214 | }); 215 | }); 216 | 217 | 218 | describe('custom comment controller', function() { 219 | var commentsConfig; 220 | beforeEach(function() { 221 | inject(function(_commentsConfig_) { 222 | commentsConfig = _commentsConfig_; 223 | }); 224 | $scope.comments = [{}]; 225 | }); 226 | 227 | it('instantiates the controller named in commentConfig', function() { 228 | commentsConfig.commentController = 'TestCtrl1'; 229 | comments = $compile('')($scope); 230 | $scope.$digest(); 231 | expect(firstCtrl().controllerName).toBe('TestCtrl1'); 232 | 233 | commentsConfig.commentController = 'TestCtrl2'; 234 | comments = $compile('')($scope); 235 | $scope.$digest(); 236 | expect(firstCtrl().controllerName).toBe('TestCtrl2'); 237 | }); 238 | 239 | 240 | it('instantiates the controller function in commentConfig', function() { 241 | commentsConfig.commentController = function($scope) { 242 | this.controllerName = 'TestCtrl3'; 243 | }; 244 | comments = $compile('')($scope); 245 | $scope.$digest(); 246 | expect(firstCtrl().controllerName).toBe('TestCtrl3'); 247 | }); 248 | 249 | 250 | it('instantiates the controller function in commentConfig with array annotation', function() { 251 | commentsConfig.commentController = ['$scope', function(s) { 252 | this.controllerName = 'TestCtrl4'; 253 | }]; 254 | comments = $compile('')($scope); 255 | $scope.$digest(); 256 | expect(firstCtrl().controllerName).toBe('TestCtrl4'); 257 | }); 258 | 259 | 260 | it('injects the comment $element into controller', function() { 261 | commentsConfig.commentController = 'TestCtrl2'; 262 | comments = $compile('')($scope); 263 | $scope.$digest(); 264 | expect(firstComment()[0]).toBe(firstCtrl().$element[0]); 265 | }); 266 | }); 267 | }); 268 | -------------------------------------------------------------------------------- /misc/demo/js/app.js: -------------------------------------------------------------------------------- 1 | function unescape(html, $sanitize) { 2 | if (!html) return ''; 3 | html = html.replace(/>/g, '>').replace(/</g, '<').replace(/&/g, '&').replace(); 4 | return $sanitize ? $sanitize(html) : html; 5 | } 6 | 7 | angular.module('commentsDemo', ['ngRoute', 'ngSanitize', 'ngAnimate', 'ui.comments']) 8 | .config(function($rootScopeProvider, $sceDelegateProvider) { 9 | //$rootScopeProvider.digestTtl(100); 10 | $sceDelegateProvider.resourceUrlWhitelist([ 11 | 'self', 12 | /.*\.redditmedia\.com.*/ 13 | ]); 14 | }) 15 | .run(function($rootScope) { 16 | $rootScope.baseUrl = window.location.href.replace(window.location.hash, ''); 17 | }) 18 | 19 | .config(function(commentsConfigProvider) { 20 | commentsConfigProvider.set({ 21 | containerTemplate: 'views/comments.html', 22 | commentTemplate: 'views/comment.html', 23 | commentController: 'CommentCtrl', 24 | depthLimit: 10 25 | }); 26 | }) 27 | 28 | .controller('CommentCtrl', function($scope, $element, $timeout) { 29 | var children; 30 | $scope.collapsed = true; 31 | $scope.$on('$filledNestedComments', function(nodes) { 32 | $scope.collapsed = true; 33 | $timeout(function() { 34 | children = nodes; 35 | children.addClass('collapse').removeClass('in'); 36 | children.collapse({ 37 | toggle: false 38 | }); 39 | // Stupid hack to wait for DOM insertion prior to setting up plugin 40 | }, 1); 41 | }); 42 | $scope.$on('$emptiedNestedComments', function(nodes) { 43 | children = undefined; 44 | }); 45 | $scope.collapse = function() { 46 | $scope.collapsed = children.hasClass('in'); 47 | children.collapse('toggle'); 48 | }; 49 | 50 | $scope.addChildComment = function(comment) { 51 | var childComment = angular.extend(comment, { 52 | name: '@'+comment.name, 53 | date: new Date(), 54 | profileUrl: 'https://github.com/' + comment.name 55 | }); 56 | if(!$scope.comment.children) { 57 | $scope.comment.children = []; 58 | } 59 | $scope.comment.children.push(childComment); 60 | }; 61 | }) 62 | 63 | .config(function($routeProvider) { 64 | $routeProvider.when('/', { 65 | templateUrl: 'views/demo.html', 66 | controller: 'DemoCtrl' 67 | }); 68 | }) 69 | 70 | .controller('DemoCtrl', function($scope, $rootScope) { 71 | $rootScope.demo = true; 72 | $rootScope.reddit = false; 73 | $rootScope.subreddit = false; 74 | $rootScope.article = false; 75 | $scope.comments = [ 76 | { 77 | name: '@caitp', 78 | date: new Date(), 79 | profileUrl: 'https://github.com/caitp', 80 | text: 'UI-Comments is designed to simplify the process of creating comment systems similar to Reddit, Imgur or Discuss in AngularJS.', 81 | children: [{ 82 | name: '@bizarro-caitp', 83 | date: new Date(), 84 | profileUrl: 'https://github.com/bizarro-caitp', 85 | text: 'We support nested comments, in a very simple fashion. It\'s great!', 86 | children: [{ 87 | name: '@caitp', 88 | date: new Date(), 89 | profileUrl: 'https://github.com/caitp', 90 | text: 'These nested comments can descend arbitrarily deep, into many levels. This can be used to reflect a long and detailed conversation about typical folly which occurs in comments', 91 | children: [{ 92 | name: '@bizarro-caitp', 93 | date: new Date(), 94 | profileUrl: 'https://github.com/bizarro-caitp', 95 | text: 'Having deep conversations on the internet can be used to drive and derive data about important topics, from marketing demographic information to political affiliation and even sexual orientation if you care to find out about that. Isn\'t that exciting?' 96 | }] 97 | },{ 98 | name: '@bizarro-caitp', 99 | date: new Date(), 100 | profileUrl: 'https://github.com/bizarro-caitp', 101 | text: 'Is it REALLY all that wonderful? People tend to populate comments with innane nonsense that ought to get them hellbanned!', 102 | comments: [{ 103 | name: '@caitp', 104 | date: new Date(), 105 | profileUrl: 'https://github.com/caitp', 106 | text: 'Oh whatever lady, whatever' 107 | }] 108 | }] 109 | }] 110 | }, { 111 | name: '@caitp', 112 | date: new Date(), 113 | profileUrl: 'https://github.com/caitp', 114 | text: 'We can have multiple threads of comments at a given moment...', 115 | }, { 116 | name: '@bizarro-caitp', 117 | date: new Date(), 118 | profileUrl: 'https://github.com/bizarro-caitp', 119 | text: 'We can do other fancy things too, maybe...', 120 | children: [{ 121 | name: '@caitp', 122 | date: new Date(), 123 | profileUrl: 'https://github.com/caitp', 124 | text: '...other fancy things, you say?', 125 | }, { 126 | name: '@caitp', 127 | date: new Date(), 128 | profileUrl: 'https://github.com/caitp', 129 | text: 'suddenly I\'m all curious, what else can we do...', 130 | children: [{ 131 | name: '@bizarro-caitp', 132 | date: new Date(), 133 | profileUrl: 'https://github.com/bizarro-caitp', 134 | text: 'Oh, you\'ll see...', 135 | }] 136 | }] 137 | }]; 138 | 139 | $scope.addParentComment = function(comment) { 140 | var parentComment = angular.extend(comment, { 141 | name: '@'+comment.name, 142 | date: new Date(), 143 | profileUrl: 'https://github.com/' + comment.name 144 | }); 145 | $scope.comments.push(parentComment); 146 | }; 147 | }) 148 | .directive('typeahead', typeheadDirective) 149 | .directive('commenter', commenterDirective) 150 | .factory('githubService', githubService) 151 | 152 | .config(function($routeProvider) { 153 | var route = { 154 | templateUrl: 'views/reddit.html', 155 | controller: 'RedditCtrl', 156 | resolve: { 157 | popular: function($http, $rootScope) { 158 | return $http.jsonp('http://api.reddit.com/subreddits/popular.json?limit=10&jsonp=JSON_CALLBACK', 159 | {cache: true}) 160 | .then(function(res) { 161 | if (!res.status || !res.data || res.status >= 400 || res.status < 200) { 162 | return []; 163 | } 164 | return $.map(res.data.data.children, mapSubreddits); 165 | function mapSubreddits(item) { 166 | var name = item.data.url.replace(/^\//, ''); 167 | return { 168 | name: name, 169 | description: item.data.public_description, 170 | short_description: item.data.public_description.length > 140 171 | ? item.data.public_description.substring(0, 140) + ' ...' 172 | : item.data.public_description, 173 | subscribers: item.data.subscribers, 174 | sfw: !item.data.over18, 175 | url: $rootScope.baseUrl + '#/' + name 176 | }; 177 | } 178 | }); 179 | } 180 | } 181 | }; 182 | $routeProvider.when('/r', route).when('/reddit', route); 183 | }) 184 | 185 | .controller('RedditCtrl', function($scope, $rootScope, $http, $location, popular) { 186 | $rootScope.demo = false; 187 | $rootScope.reddit = true; 188 | $rootScope.subreddit = false; 189 | $rootScope.article = false; 190 | $scope.name = ''; 191 | $scope.popular = popular; 192 | $scope.go = function(name) { 193 | var escaped = escape(name); 194 | $http.jsonp('http://api.reddit.com/r/'+ escaped +'/about.json' + 195 | '?jsonp=JSON_CALLBACK', {cache: true}) 196 | .success(function(data) { 197 | $location.path('/r/' + escaped); 198 | }); 199 | }; 200 | }) 201 | 202 | .config(function($routeProvider) { 203 | $routeProvider.when('/r/:subreddit', { 204 | templateUrl: 'views/subreddit.html', 205 | controller: 'SubRedditCtrl', 206 | resolve: { 207 | subreddit: function($route, $http) { 208 | delete $http.defaults.headers.common['X-Requested-With']; 209 | return $http.jsonp('http://api.reddit.com/r/'+$route.current.params.subreddit+'/hot.json' + 210 | '?jsonp=JSON_CALLBACK&limit=10', {cache: true}); 211 | }, 212 | about: function($route, $http) { 213 | delete $http.defaults.headers.common['X-Requested-With']; 214 | return $http.jsonp('http://api.reddit.com/r/'+$route.current.params.subreddit+'/about.json' + 215 | '?jsonp=JSON_CALLBACK', {cache: true}); 216 | } 217 | } 218 | }); 219 | }) 220 | 221 | .controller('SubRedditCtrl', function($scope, subreddit, about, $rootScope) { 222 | if (angular.isFunction(subreddit.headers)) { 223 | subreddit = subreddit.data; 224 | } 225 | if (angular.isFunction(about.headers)) { 226 | about = about.data; 227 | } 228 | $scope.subreddit = 'r/' + about.data.display_name; 229 | $scope.description = about.data.public_description || about.data.description || ""; 230 | var articles = subreddit && subreddit.data && subreddit.data.children && subreddit.data.children, 231 | badthumbs = /^(self|default|nsfw)$/i; 232 | $scope.articles = $.map(articles, mapArticles) || []; 233 | 234 | $rootScope.demo = false; 235 | $rootScope.reddit = false; 236 | $rootScope.subreddit = $scope.subreddit; 237 | $rootScope.article = false; 238 | 239 | function mapArticles(item) { 240 | return { 241 | domain: item.data.domain, 242 | author: '@' + item.data.author, 243 | profileUrl: 'http://www.reddit.com/user/' + item.data.author + '/', 244 | score: item.data.score, 245 | thumbnail: (!badthumbs.test(item.data.thumbnail) && item.data.thumbnail) || undefined, 246 | title: item.data.title, 247 | url: item.data.url, 248 | id: item.data.id, 249 | comments: $scope.baseUrl + '#/' + $scope.subreddit + '/' + item.data.id 250 | }; 251 | } 252 | }) 253 | 254 | .config(function($routeProvider) { 255 | $routeProvider.when('/r/:subreddit/:article', { 256 | templateUrl: 'views/article.html', 257 | controller: 'RedditArticleCtrl', 258 | resolve: { 259 | article: function($route, $http, $q, $sanitize) { 260 | delete $http.defaults.headers.common['X-Requested-With']; 261 | var promises = { 262 | comments: $http.jsonp('http://api.reddit.com/r/'+$route.current.params.subreddit+'/comments/' + 263 | $route.current.params.article + '.json?limit=10&jsonp=JSON_CALLBACK', {cache: true}), 264 | about: $http.jsonp('http://api.reddit.com/r/'+$route.current.params.subreddit+'/about.json' + 265 | '?jsonp=JSON_CALLBACK', {cache: true}) 266 | }; 267 | 268 | return $q.all(promises).then(setup); 269 | 270 | function setup(data) { 271 | var comments = data.comments, about = data.about; 272 | if (angular.isFunction(comments.headers)) { 273 | comments = comments.data; 274 | } 275 | if (angular.isFunction(about.headers)) { 276 | about = about.data; 277 | } 278 | var article = angular.isArray(comments) && angular.isObject(comments[0].data) && comments[0].data; 279 | comments = angular.isArray(comments) && comments.length > 0 && 280 | angular.isObject(comments[1].data) && comments[1].data; 281 | article = angular.isArray(article.children) && article.children.length && article.children[0]; 282 | article = article.data; 283 | 284 | return { 285 | subreddit: about.data.display_name.replace(/^\/?r\//, ''), 286 | description: about.data.public_description || about.data.description || "", 287 | comments: comments && $.map(comments.children, mapComments), 288 | url: article ? article.url : "#", 289 | title: article.title || "Invalid article", 290 | selftext: article && unescape(article.selftext_html, $sanitize), 291 | thumbnail: (article.thumbnail !== "self" && article.thumbnail) || undefined, 292 | id: article.id 293 | }; 294 | 295 | function mapComments(item) { 296 | if (!item.data || !item.data.author) { 297 | return undefined; 298 | } 299 | var children = angular.isObject(item) && angular.isObject(item.data) && angular.isObject(item.data.replies) && 300 | angular.isObject(item.data.replies.data) && angular.isArray(item.data.replies.data.children) && 301 | item.data.replies.data.children; 302 | return { 303 | name: '@' + item.data.author, 304 | date: new Date(item.data.created_utc * 1000), 305 | profileUrl: 'http://www.reddit.com/user/' + item.data.author + '/', 306 | text: unescape(item.data.body_html, $sanitize), 307 | children: $.map(children, mapComments) || [] 308 | }; 309 | } 310 | } 311 | } 312 | } 313 | }); 314 | }) 315 | 316 | .controller('RedditArticleCtrl', function($scope, article, $rootScope) { 317 | angular.extend($scope, article); 318 | $rootScope.demo = false; 319 | $rootScope.reddit = false; 320 | $rootScope.subreddit = 'r/' + article.subreddit; 321 | $rootScope.article = article.id; 322 | }) 323 | 324 | .filter('timeago', function() { 325 | return function(date) { 326 | return moment(date).fromNow(); 327 | }; 328 | }) 329 | .filter('calendar', function() { 330 | return function(date) { 331 | return moment(date).calendar(); 332 | }; 333 | }); 334 | -------------------------------------------------------------------------------- /src/comments.js: -------------------------------------------------------------------------------- 1 | angular.module('ui.comments.directive', []) 2 | 3 | /** 4 | * @ngdoc service 5 | * @name ui.comments.commentsConfig 6 | * @function 7 | * 8 | * @description 9 | * 10 | * The commentsConfig service offers a provider which may be injected 11 | * into config blocks: 12 | * 13 | *
 14 |  * angular.module('example', ['ui.comments'])
 15 |  * .config(function(commentsConfig) {
 16 |  *   commentsConfig.set('commentController', 'MyController');
 17 |  *   commentsConfig.set({
 18 |  *     containerTemplate: 'my/custom/views/comments.html'
 19 |  *   });
 20 |  * });
 21 |  * 
22 | * 23 | * Injected as a service, it is simply the configuration object in its current state. 24 | * 25 | * It is wise not to write to the service outside of config blocks, because the 26 | * set() method provides some safety checks to ensure that only valid values are 27 | * written. It should not be necessary for an application to inject commentsConfig anywhere 28 | * except config blocks. 29 | */ 30 | .provider('commentsConfig', function() { 31 | var config = { 32 | /** 33 | * @ngdoc property 34 | * @name ui.comments.commentsConfig#containerTemplate 35 | * @propertyOf ui.comments.commentsConfig 36 | * 37 | * @description 38 | * 39 | * The template URL for collections of comments. Support for inline templates is not yet 40 | * available, and so this must be a valid URL or cached ng-template 41 | */ 42 | containerTemplate: 'template/comments/comments.html', 43 | /** 44 | * @ngdoc property 45 | * @name ui.comments.commentsConfig#commentTemplate 46 | * @propertyOf ui.comments.commentsConfig 47 | * 48 | * @description 49 | * 50 | * The template URL for a single comment. Support for inline templates is not yet 51 | * available, and so this must be a valid URL or cached ng-template 52 | * 53 | * If this template manually includes a {@link ui.comments.directive:comments comments} 54 | * directive, it will result in an infinite $compile loop. Instead, 55 | * {@link ui.comments.directive:comment comment} generates child collections programmatically. 56 | * Currently, these are simply appended to the body of the comment. 57 | */ 58 | commentTemplate: 'template/comments/comment.html', 59 | /** 60 | * @ngdoc property 61 | * @name ui.comments.commentsConfig#orderBy 62 | * @propertyOf ui.comments.commentsConfig 63 | * 64 | * @description 65 | * 66 | * Presently, this configuration item is not actually used. 67 | * 68 | * **TODO**: Its intended purpose is to provide a default comment ordering rule. However, 69 | * currently there is no machinery for ordering templates at all. This is intended for a later 70 | * release. 71 | */ 72 | orderBy: 'best', 73 | /** 74 | * @ngdoc property 75 | * @name ui.comments.commentsConfig#commentController 76 | * @propertyOf ui.comments.commentsConfig 77 | * 78 | * @description 79 | * 80 | * Custom controller to be instantiated for each comment. The instantiated controller is 81 | * given the property `$element` in scope. This allows the instantiated controller 82 | * to bind to comment events. 83 | * 84 | * The controller may be specified either as a registered controller (string), a function, 85 | * or by array notation. 86 | * 87 | */ 88 | commentController: undefined, 89 | /** 90 | * @ngdoc property 91 | * @name ui.comments.commentsConfig#depthLimit 92 | * @propertyOf ui.comments.commentsConfig 93 | * 94 | * @description 95 | * 96 | * Default maximum depth of comments to display in a comments collection. When the depth 97 | * limit is exceeded, no further child comments collections shall be created. 98 | * 99 | * The depth limit may also be specified via the `comment-depth-limit` attribute for the 100 | * {@link ui.comments.directive:comments comments} directive. 101 | */ 102 | depthLimit: 5 103 | }; 104 | var emptyController = function() {}; 105 | function stringSetter(setting, value) { 106 | if (typeof value === 'string') { 107 | config[setting] = value; 108 | } 109 | } 110 | function controllerSetter(setting, value) { 111 | if (value && (angular.isString(value) && value.length || 112 | angular.isFunction(value) || 113 | angular.isArray(value))) { 114 | config[setting] = value; 115 | } else { 116 | config[setting] = emptyController; 117 | } 118 | } 119 | function numberSetter(setting, value) { 120 | if (typeof value === 'number') { 121 | config[setting] = value; 122 | } 123 | } 124 | 125 | var setters = { 126 | 'containerTemplate': stringSetter, 127 | 'commentTemplate': stringSetter, 128 | 'orderBy': stringSetter, 129 | 'commentController': controllerSetter, 130 | 'depthLimit': numberSetter 131 | }; 132 | this.$get = function() { 133 | return config; 134 | }; 135 | 136 | /** 137 | * @ngdoc function 138 | * @name ui.comments.commentsConfig#set 139 | * @methodOf ui.comments.commentsConfig 140 | * @function 141 | * 142 | * @description 143 | * 144 | * _When injected into a config block_, this method allows the manipulate the comments 145 | * configuration. 146 | * 147 | * This method performs validation and only permits the setting of known properties, and 148 | * will only set values of acceptable types. Further validation, such as detecting whether or 149 | * not a controller is actually registered, is not performed. 150 | * 151 | * @param {string|object} name Either the name of the property to be accessed, or an object 152 | * containing keys and values to extend the configuration with. 153 | * 154 | * @param {*} value The value to set the named key to. Its type depends on the 155 | * property being set. 156 | * 157 | * @returns {undefined} Currently, this method is not chainable. 158 | */ 159 | this.set = function(name, value) { 160 | var fn, key, props, i; 161 | if (typeof name === 'string') { 162 | fn = setters[name]; 163 | if (fn) { 164 | fn(name, value); 165 | } 166 | } else if (typeof name === 'object') { 167 | props = Object.keys(name); 168 | for(i=0; i 209 | *
210 | * 211 | *
212 | * 213 | */ 214 | .directive('comments', function($compile, $interpolate, commentsConfig) { 215 | return { 216 | restrict: 'EA', 217 | require: '?^comment', 218 | transclude: true, 219 | replace: true, 220 | templateUrl: function() { return commentsConfig.containerTemplate; }, 221 | scope: { 222 | 'comments': '=commentData' 223 | }, 224 | controller: function() {}, 225 | link: { 226 | pre: function(scope, elem, attr, comment) { 227 | var self = elem.controller('comments'), 228 | parentCollection = comment ? comment.comments : null; 229 | 230 | // Setup $commentsController 231 | if (parentCollection) { 232 | self.commentsDepth = parentCollection.commentsDepth + 1; 233 | self.commentsRoot = parentCollection.commentsRoot; 234 | self.commentsParent = parentCollection; 235 | } else { 236 | self.commentsDepth = 1; 237 | self.commentsRoot = null; 238 | var depthLimit = angular.isDefined(attr.commentDepthLimit) ? 239 | attr.commentDepthLimit : 240 | commentsConfig.depthLimit; 241 | if (typeof depthLimit === 'string') { 242 | depthLimit = $interpolate(depthLimit, false)(scope.$parent); 243 | if (typeof depthLimit === 'string') { 244 | depthLimit = parseInt(depthLimit, 10); 245 | } 246 | } 247 | 248 | if (typeof depthLimit !== 'number' || depthLimit !== depthLimit) { 249 | // Avoid NaN and non-numbers 250 | depthLimit = 0; 251 | } 252 | 253 | self.commentsDepthLimit = depthLimit; 254 | } 255 | 256 | scope.commentsDepth = self.commentsDepth; 257 | attr.$observe('orderBy', function(newval, oldval) { 258 | scope.commentOrder = newval || commentsConfig.orderBy; 259 | }); 260 | } 261 | } 262 | }; 263 | }) 264 | 265 | /** 266 | * @ngdoc directive 267 | * @name ui.comments.directive:comment 268 | * @restrict EA 269 | * @element div 270 | * @scope 271 | * 272 | * @param {expression} comment-data Data model containing the specific comment. 273 | * 274 | * @description 275 | * 276 | * The {@link ui.comments.directive:comment comment} directive is primarily used automatically by 277 | * the {@link ui.comments.directive:comments comments} directive, by automatically 278 | * building comment directives for each element in the collection. 279 | * 280 | * When a {@link ui.comments.commentsConfig#commentController commentController} is specified in 281 | * {@link ui.comments.commentsConfig commentsConfig}, it is instantiated for each comment. The 282 | * comment data is exposed to scope through {@link ui.comments.directive:comment comment}, and so 283 | * templates may access comment properties through expressions like `comment.text`. 284 | * 285 | * An example template might look like the following: 286 | *
287 |  * 
288 | *
289 | * 291 | * {{comment.name}} 293 | * 294 | * {{comment.name}} 297 | * {{comment.date | timeAgo}} 298 | *
299 | *
300 | *
301 | *
302 | * 303 | * **IMPORANT**: Do **not** use the {@link ui.comments.directive:comments comments} directive in a 304 | * {@link ui.comments.commentsConfig#commentTemplate commentTemplate}. This will cause an 305 | * infinite {@link http://docs.angularjs.org/api/ng.$compile $compile} loop, and eat a lot of 306 | * memory. 307 | */ 308 | .directive('comment', function($compile, commentsConfig, $controller, $exceptionHandler, $timeout) { 309 | return { 310 | require: ['^comments', 'comment'], 311 | restrict: 'EA', 312 | transclude: true, 313 | replace: true, 314 | templateUrl: function() { return commentsConfig.commentTemplate; }, 315 | scope: { 316 | comment: '=commentData' 317 | }, 318 | controller: function($scope) {}, 319 | link: function(scope, elem, attr, ctrls) { 320 | var comments = ctrls[0], comment = ctrls[1]; 321 | var controller = commentsConfig.commentController, controllerInstance; 322 | 323 | scope.commentDepth = comments.commentsDepth; 324 | scope.commentDepthLimit = (comments.commentsRoot || comments).commentsDepthLimit; 325 | comment.comments = comments; 326 | 327 | if (controller) { 328 | controllerInstance = $controller(controller, { 329 | '$scope': scope, 330 | '$element': elem 331 | }); 332 | if (controllerInstance) { 333 | elem.data('$CommentController', controllerInstance); 334 | } 335 | } 336 | if (elem.parent().attr('child-comments') === 'true') { 337 | elem.addClass('child-comment'); 338 | } 339 | var children = false, compiled, 340 | sub = $compile('
'), 342 | transclude; 343 | // Notify controller without bubbling 344 | function notify(scope, name, data) { 345 | if (!controllerInstance) { return; } 346 | var namedListeners = scope.$$listeners[name] || [], i, length, args = [data]; 347 | for (i=0, length=namedListeners.length; i 0 && !children) { 368 | if (comments.commentsDepth >= (comments.commentsRoot || comments).commentsDepthLimit) { 369 | notify(scope, '$depthLimitComments', scope.comment); 370 | return; 371 | } 372 | compiled = sub(scope, function(dom) { 373 | if (comment.commentsTransclude) { 374 | transclude = comment.commentsTransclude.clone(true); 375 | comment.commentsTransclude.replaceWith(dom); 376 | } else { 377 | elem.append(dom); 378 | } 379 | }); 380 | children = true; 381 | notify(scope, '$filledNestedComments', compiled); 382 | } else if(!data.length && children) { 383 | children = false; 384 | if (comment.commentsTransclude && transclude) { 385 | compiled.replaceWith(transclude); 386 | } else { 387 | compiled.remove(); 388 | } 389 | notify(scope, '$emptiedNestedComments', comment.commentsTransclude || elem); 390 | transclude = compiled = undefined; 391 | } 392 | } 393 | 394 | scope.$watch('comment', function(newval) { 395 | update(scope.comment.children); 396 | }, true); 397 | } 398 | }; 399 | }) 400 | 401 | /** 402 | * @ngdoc directive 403 | * @name ui.comments.directive:commentsTransclude 404 | * @restrict EA 405 | * @element div 406 | * 407 | * @description 408 | * 409 | * This directive is a helper which allows a user to specify in a 410 | * {@link ui.comments.directive:comment comment} template where they wish child comments to be 411 | * inserted. 412 | * 413 | * If this directive is not used, then the child comments are merely appended to the end of a 414 | * comment template, which is the behaviour of the default templates. 415 | * 416 | * It is not a literal transclusion, and so any class names or categories are completely ignored. 417 | * Instead, the element is replaced by a {@link ui.comments.directive:comments comments} collection 418 | * when comments are available. 419 | * 420 | * An example template might look like the following: 421 | *
422 |  * 
423 | *
424 | * 426 | * {{comment.name}} 428 | * 429 | * {{comment.name}} 432 | * {{comment.date | timeAgo}} 433 | *
434 | *
435 | *
436 | *
437 | *
438 | */ 439 | .directive('commentsTransclude', function() { 440 | return { 441 | restrict: 'EA', 442 | require: '^comment', 443 | link: function(scope, element, attr, comment) { 444 | attr.$addClass('comments-transclude'); 445 | comment.commentsTransclude = element; 446 | } 447 | }; 448 | }); -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | var markdown = require('node-markdown').Markdown; 2 | var bower = require('bower'); 3 | 4 | module.exports = function(grunt) { 5 | 6 | grunt.loadNpmTasks('grunt-contrib-watch'); 7 | grunt.loadNpmTasks('grunt-contrib-concat'); 8 | grunt.loadNpmTasks('grunt-contrib-copy'); 9 | grunt.loadNpmTasks('grunt-contrib-jshint'); 10 | grunt.loadNpmTasks('grunt-contrib-uglify'); 11 | grunt.loadNpmTasks('grunt-html2js'); 12 | grunt.loadNpmTasks('grunt-karma'); 13 | grunt.loadNpmTasks('grunt-conventional-changelog'); 14 | grunt.loadNpmTasks('grunt-ngdocs-caitp'); 15 | grunt.loadNpmTasks('grunt-ngmin'); 16 | grunt.loadNpmTasks('grunt-gh-pages'); 17 | grunt.loadNpmTasks('grunt-contrib-connect'); 18 | grunt.loadNpmTasks('grunt-contrib-clean'); 19 | 20 | // Project configuration. 21 | grunt.util.linefeed = '\n'; 22 | var pkg = grunt.file.readJSON('package.json'); 23 | grunt.initConfig({ 24 | modules: [], 25 | pkg: pkg, 26 | dist: 'dist', 27 | filename: 'ui-comments', 28 | meta: { 29 | modules: 'angular.module("ui.comments");', 30 | tplmodules: 'angular.module("ui.comments.tpls", [<%= tplModules %>]);', 31 | all: 'angular.module("ui.comments", ["ui.comments.tpls", <%= srcModules %>]);' 32 | }, 33 | delta: { 34 | html: { 35 | files: ['template/**/*.html'], 36 | tasks: ['html2js', 'karma:watch:run'] 37 | }, 38 | js: { 39 | files: ['src/**/*.js'], 40 | // we don't need to jshint here, it slows down everything else. 41 | tasks: ['karma:watch:run'] 42 | } 43 | }, 44 | concat: { 45 | dist: { 46 | options: { 47 | banner: '<%= meta.all %>\n<%= meta.tplmodules %>\n' 48 | }, 49 | src: [], // src filled in by build task 50 | dest: '<%= dist %>/<%= filename %>-<%= pkg.version %>.js' 51 | } 52 | }, 53 | copy: { 54 | demohtml: { 55 | options: { 56 | // process html files with gruntfile config 57 | processContent: grunt.template.process 58 | }, 59 | files: [{ 60 | expand: true, 61 | src: ["**/*.html"], 62 | cwd: "misc/demo/", 63 | dest: "<%= dist %>/" 64 | }] 65 | }, 66 | demoassets: { 67 | files: [{ 68 | expand: true, 69 | // Don't re-copy html files, we process those 70 | src: ["**/**/*", "!**/*.html"], 71 | cwd: "misc/demo", 72 | dest: "<%= dist %>/" 73 | }] 74 | }, 75 | bower: { 76 | files: [{ 77 | expand: true, 78 | src: ["*.min.js", "*.min.js.map"], 79 | cwd: "bower_components/angular", 80 | dest: "<%= dist %>/js" 81 | }, { 82 | expand: true, 83 | src: ["*.min.js", "*.min.js.map"], 84 | cwd: "bower_components/angular-animate", 85 | dest: "<%= dist %>/js" 86 | }, { 87 | expand: true, 88 | src: ["*.min.js", "*.min.js.map"], 89 | cwd: "bower_components/angular-route", 90 | dest: "<%= dist %>/js" 91 | }, { 92 | expand: true, 93 | src: ["*.min.js", "*.min.js.map"], 94 | cwd: "bower_components/angular-sanitize", 95 | dest: "<%= dist %>/js" 96 | }, { 97 | expand: true, 98 | src: ["*.min.js", "*.min.js.map"], 99 | cwd: "bower_components/bootstrap/dist/js", 100 | dest: "<%= dist %>/js" 101 | }, { 102 | expand: true, 103 | src: ["bootstrap.min.css"], 104 | cwd: "bower_components/bootstrap/dist/css", 105 | dest: "<%= dist %>/css" 106 | }, { 107 | expand: true, 108 | src: ["*"], 109 | cwd: "bower_components/bootstrap/dist/fonts", 110 | dest: "<%= dist %>/fonts" 111 | }, { 112 | expand: true, 113 | src: ["jquery.min.js", "jquery.min.map"], 114 | cwd: "bower_components/jquery/", 115 | dest: "<%= dist %>/js" 116 | }, { 117 | expand: true, 118 | src: ["moment.min.js"], 119 | cwd: "bower_components/momentjs/min", 120 | dest: "<%= dist %>/js" 121 | }, { 122 | expand: true, 123 | src: ["*.min.css"], 124 | cwd: "bower_components/font-awesome/css", 125 | dest: "<%= dist %>/css" 126 | }, { 127 | expand: true, 128 | src: ["*"], 129 | cwd: "bower_components/font-awesome/fonts", 130 | dest: "<%= dist %>/fonts" 131 | }] 132 | } 133 | }, 134 | uglify: { 135 | dist: { 136 | options: { 137 | mangle: { 138 | except: ['angular'] 139 | }, 140 | }, 141 | files: { 142 | '<%= dist %>/<%= filename %>-<%= pkg.version %>.min.js': 143 | '<%= dist %>/<%= filename %>-<%= pkg.version %>.js' 144 | } 145 | } 146 | }, 147 | html2js: { 148 | dist: { 149 | options: { 150 | module: null, // no bundle module for all the html2js templates 151 | base: '.' 152 | }, 153 | files: [{ 154 | expand: true, 155 | src: ['template/**/*.html'], 156 | ext: '.html.js' 157 | }] 158 | }, 159 | }, 160 | jshint: { 161 | options: { 162 | curly: true, 163 | immed: true, 164 | newcap: true, 165 | noarg: true, 166 | sub: true, 167 | boss: true, 168 | eqnull: true, 169 | maxlen: 100, 170 | trailing: true, 171 | undef: true, 172 | }, 173 | gruntfile: { 174 | options: { 175 | node: true, 176 | globals: { 177 | "console": true 178 | } 179 | }, 180 | files: [{ 181 | src: 'Gruntfile.js' 182 | }] 183 | }, 184 | sources: { 185 | options: { 186 | globals: { 187 | "console": true, 188 | angular: true, 189 | jQuery: true, 190 | document: true 191 | } 192 | }, 193 | files: [{ 194 | src: ['src/*.js', '!src/*.spec.js'] 195 | }] 196 | } 197 | }, 198 | karma: { 199 | options: { 200 | configFile: 'karma.conf.js' 201 | }, 202 | watch: { 203 | background: true 204 | }, 205 | continuous: { 206 | singleRun: true 207 | }, 208 | jenkins: { 209 | singleRun: true, 210 | colors: false, 211 | reporter: ['dots', 'junit'], 212 | browsers: [ 213 | 'Chrome', 214 | 'ChromeCanary', 215 | 'Firefox', 216 | 'Opera', 217 | '/Users/jenkins/bin/safari.sh', 218 | '/Users/jenkins/bin/ie9.sh' 219 | ] 220 | }, 221 | travis: { 222 | singleRun: true, 223 | browsers: ['PhantomJS', 'Firefox'] 224 | } 225 | }, 226 | changelog: { 227 | options: { 228 | dest: 'CHANGELOG.md', 229 | templateFile: 'misc/changelog.tpl.md', 230 | github: 'caitp/ui-comments' 231 | } 232 | }, 233 | shell: { 234 | // We use %version% and evaluate it at run-time, because 235 | // <%= pkg.version is only evaluated once 236 | 'release-prepare': [ 237 | 'grunt before-test after-test', 238 | 'grunt clean:dist', 239 | 'grunt version', // remove "-SNAPSHOT" 240 | 'grunt before-test after-test', 241 | 'grunt docgen:%version%', 242 | 'grunt changelog', 243 | ], 244 | 'release-complete': [ 245 | 'git commit CHANGELOG.md package.json -m "chore(release): v%version%"', 246 | 'git tag v%version%', 247 | ], 248 | 'release-start': [ 249 | 'grunt version:%PATCHTYPE%:"SNAPSHOT"', 250 | 'git commit package.json -m "chore(release): Starting v%version%"' 251 | ] 252 | }, 253 | ngmin: { 254 | lib: { 255 | src: ['<%= dist %>/ui-comments-<%= pkg.version %>.js'], 256 | dest: '<%= dist %>/ui-comments-<%= pkg.version %>.js' 257 | } 258 | }, 259 | ngdocs: { 260 | options: { 261 | dest: "<%= dist %>/docs", 262 | scripts: [ 263 | 'angular.js', 264 | '<%= concat.dist.dest %>' 265 | ], 266 | styles: [ 267 | 'docs/css/style.css' 268 | ], 269 | navTemplate: 'docs/nav.html', 270 | title: 'UI Comments', 271 | image: 'logo.png', 272 | imageLink: 'http://caitp.github.io/ui-comments', 273 | titleLink: 'http://caitp.github.io/ui-comments', 274 | html5Mode: false, 275 | analytics: { 276 | account: 'UA-44389518-1', 277 | domainName: 'caitp.github.io' 278 | } 279 | }, 280 | api: { 281 | src: ["src/comments.js", "src/**/*.ngdoc"], 282 | title: "API Documentation" 283 | } 284 | }, 285 | 'gh-pages': { 286 | 'gh-pages': { 287 | options: { 288 | base: '<%= dist %>', 289 | repo: 'https://github.com/caitp/ui-comments.git', 290 | message: 'gh-pages v<%= pkg.version %>', 291 | add: true 292 | }, 293 | src: ['**/*'] 294 | } 295 | }, 296 | connect: { 297 | docs: { 298 | options: { 299 | port: process.env.PORT || '3000', 300 | base: '<%= dist %>/docs', 301 | keepalive: true 302 | } 303 | }, 304 | dev: { 305 | options: { 306 | port: process.env.PORT || '3000', 307 | base: '<%= dist %>', 308 | keepalive: true 309 | } 310 | } 311 | }, 312 | clean: { 313 | dist: { 314 | src: ['<%= dist %>', 'dist'] 315 | } 316 | } 317 | }); 318 | 319 | // register before and after test tasks so we don't have to change cli 320 | // options on the CI server 321 | grunt.registerTask('before-test', ['enforce', 'jshint', 'html2js']); 322 | grunt.registerTask('after-test', ['build', 'copy']); 323 | 324 | // Rename our watch task to 'delta', then make actual 'watch' task build 325 | // things, then start test server 326 | grunt.renameTask('watch', 'delta'); 327 | grunt.registerTask('watch', ['bower', 'before-test', 'after-test', 'karma:watch', 'delta']); 328 | 329 | // Default task. 330 | grunt.registerTask('default', ['bower', 'before-test', 'test', 'after-test']); 331 | grunt.registerTask('all', ['default']); 332 | 333 | grunt.registerTask('enforce', 'Install commit message enforce script if it doesn\'t exist', 334 | function() { 335 | if (!grunt.file.exists('.git/hooks/commit-msg')) { 336 | grunt.file.copy('misc/validate-commit-msg.js', '.git/hooks/commit-msg'); 337 | require('fs').chmodSync('.git/hooks/commit-msg', '0755'); 338 | } 339 | }); 340 | 341 | // Test 342 | grunt.registerTask('test', 'Run tests on singleRun karma server', function() { 343 | // This task can be executed in 3 different environments: local, Travis-CI, 344 | // and Jenkins-CI. We need to take settings for each one into account 345 | if (process.env.TRAVIS) { 346 | grunt.task.run('karma:travis'); 347 | } else { 348 | grunt.task.run(this.args.length ? 'karma:jenkins' : 'karma:continuous'); 349 | } 350 | }); 351 | 352 | // Shell commands 353 | grunt.registerMultiTask('shell', 'Run shell commands', function() { 354 | var self = this, sh = require('shelljs'); 355 | self.data.forEach(function(cmd) { 356 | cmd = cmd.replace('%version%', grunt.file.readJSON('package.json').version); 357 | cmd = cmd.replace('%PATCHTYPE%', grunt.option('patch') && 'patch' || 358 | grunt.option('major') && 359 | 'major' || 'minor'); 360 | grunt.log.ok(cmd); 361 | var result = sh.exec(cmd, {silent: true }); 362 | if (result.code !== 0) { 363 | grunt.fatal(result.output); 364 | } 365 | }); 366 | }); 367 | 368 | // Version management 369 | function setVersion(type, suffix) { 370 | var file = 'package.json', 371 | VERSION_REGEX = /([\'|\"]version[\'|\"][ ]*:[ ]*[\'|\"])([\d|.]*)(-\w+)*([\'|\"])/, 372 | contents = grunt.file.read(file), 373 | version; 374 | contents = contents.replace(VERSION_REGEX, function(match, left, center) { 375 | version = center; 376 | if (type) { 377 | version = require('semver').inc(version, type); 378 | } 379 | // semver.inc strips suffix, if it existed 380 | if (suffix) { 381 | version += '-' + suffix; 382 | } 383 | return left + version + '"'; 384 | }); 385 | grunt.log.ok('Version set to ' + version.cyan); 386 | grunt.file.write(file, contents); 387 | return version; 388 | } 389 | 390 | grunt.registerTask('version', 'Set version. If no arguments, it just takes off suffix', 391 | function() { 392 | setVersion(this.args[0], this.args[1]); 393 | }); 394 | 395 | // Dist 396 | grunt.registerTask('dist', 'Override dist directory', function() { 397 | var dir = this.args[0]; 398 | if (dir) { 399 | grunt.config('dist', dir ); 400 | } 401 | }); 402 | 403 | var foundModules = {}; 404 | function findModule(name) { 405 | if (foundModules[name]) { return; } 406 | foundModules[name] = true; 407 | 408 | function breakup(text, separator) { 409 | return text.replace(/[A-Z]/g, function (match) { 410 | return separator + match; 411 | }); 412 | } 413 | function ucwords(text) { 414 | return text.replace(/^([a-z])|\s+([a-z])/g, function ($1) { 415 | return $1.toUpperCase(); 416 | }); 417 | } 418 | function enquote(str) { 419 | return '"' + str + '"'; 420 | } 421 | 422 | var modname = name; 423 | if (name === 'comments') { 424 | modname = 'comments.directive'; 425 | } 426 | var module = { 427 | name: name, 428 | moduleName: enquote('ui.' + modname), 429 | displayName: ucwords(breakup(name, ' ')), 430 | srcFiles: grunt.file.expand({ignore: "*.spec.js"}, "src/"+name+".js"), 431 | tplFiles: grunt.file.expand("template/"+name+"/*.html"), 432 | tpljsFiles: grunt.file.expand("template/"+name+"/*.html.js"), 433 | tplModules: grunt.file.expand("template/"+name+"/*.html").map(enquote), 434 | dependencies: dependenciesForModule(name), 435 | docs: { 436 | md: grunt.file.expand("src/"+name+"/docs/*.md") 437 | .map(grunt.file.read).map(markdown).join("\n"), 438 | js: grunt.file.expand("src/"+name+"/docs/*.js") 439 | .map(grunt.file.read).join("\n"), 440 | html: grunt.file.expand("src/"+name+"/docs/*.html") 441 | .map(grunt.file.read).join("\n") 442 | } 443 | }; 444 | module.dependencies.forEach(findModule); 445 | grunt.config('modules', grunt.config('modules').concat(module)); 446 | } 447 | 448 | function dependenciesForModule(name) { 449 | var deps = []; 450 | grunt.file.expand('src/*.js') 451 | .map(grunt.file.read) 452 | .forEach(function(contents) { 453 | //Strategy: find where module is declared, 454 | //and from there get everything inside the [] and split them by comma 455 | var moduleDeclIndex = contents.indexOf('angular.module('); 456 | var depArrayStart = contents.indexOf('[', moduleDeclIndex); 457 | var depArrayEnd = contents.indexOf(']', depArrayStart); 458 | var dependencies = contents.substring(depArrayStart + 1, depArrayEnd); 459 | dependencies.split(',').forEach(function(dep) { 460 | if (dep.indexOf('ui.comments.') > -1) { 461 | var depName = dep.trim().replace('ui.comments.','').replace(/['"]/g,''); 462 | if (deps.indexOf(depName) < 0) { 463 | deps.push(depName); 464 | //Get dependencies for this new dependency 465 | deps = deps.concat(dependenciesForModule(depName)); 466 | } 467 | } 468 | }); 469 | }); 470 | return deps; 471 | } 472 | 473 | // Build 474 | grunt.registerTask('build', 'Create ui-comments build files', function() { 475 | var _ = grunt.util._; 476 | 477 | grunt.file.expand({ 478 | filter: 'isFile', cwd: '.' 479 | }, 'src/*.js').forEach(function(file) { 480 | findModule(file.split('/')[1].split('.')[0]); 481 | }); 482 | 483 | var modules = grunt.config('modules'); 484 | grunt.config('srcModules', _.pluck(modules, 'moduleName')); 485 | grunt.config('tplModules', _.pluck(modules, 'tplModules') 486 | .filter(function(tpls) { 487 | return tpls.length > 0; 488 | }) 489 | ); 490 | grunt.config('demoModules', 491 | modules.filter(function(module) { 492 | return module.docs.md && module.docs.js && module.docs.html; 493 | }) 494 | .sort(function(a, b) { 495 | if (a.name < b.name) { return -1; } 496 | if (a.name > b.name) { return 1; } 497 | return 0; 498 | })); 499 | 500 | var srcFiles = _.pluck(modules, 'srcFiles'); 501 | var tpljsFiles = _.pluck(modules, 'tpljsFiles'); 502 | 503 | grunt.config('concat.dist.src', 504 | grunt.config('concat.dist.src') 505 | .concat(srcFiles) 506 | .concat(tpljsFiles)); 507 | 508 | grunt.task.run(['concat', 'ngmin', 'uglify', 'ngdocs']); 509 | }); 510 | 511 | grunt.registerTask('docgen', function() { 512 | var self = this; 513 | if (typeof self.args[0] === 'string') { 514 | grunt.config('pkg.version', self.args[0]); 515 | } 516 | grunt.task.mark().run('gh-pages'); 517 | }); 518 | 519 | grunt.registerTask('bower', 'Install Bower packages.', function() { 520 | var done = this.async(); 521 | bower.commands.install() 522 | .on('log', function (result) { 523 | grunt.log.ok('bower: ' + result.id + ' ' + result.data.endpoint.name); 524 | }) 525 | .on('error', grunt.fail.warn.bind(grunt.fail)) 526 | .on('end', done); 527 | }); 528 | 529 | return grunt; 530 | }; 531 | -------------------------------------------------------------------------------- /misc/test/angular-mocks.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license AngularJS v1.2.28 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.$$checkUrlChange = angular.noop; 67 | 68 | self.cookieHash = {}; 69 | self.lastCookieHash = {}; 70 | self.deferredFns = []; 71 | self.deferredNextId = 0; 72 | 73 | self.defer = function(fn, delay) { 74 | delay = delay || 0; 75 | self.deferredFns.push({time:(self.defer.now + delay), fn:fn, id: self.deferredNextId}); 76 | self.deferredFns.sort(function(a,b){ return a.time - b.time;}); 77 | return self.deferredNextId++; 78 | }; 79 | 80 | 81 | /** 82 | * @name $browser#defer.now 83 | * 84 | * @description 85 | * Current milliseconds mock time. 86 | */ 87 | self.defer.now = 0; 88 | 89 | 90 | self.defer.cancel = function(deferId) { 91 | var fnIndex; 92 | 93 | angular.forEach(self.deferredFns, function(fn, index) { 94 | if (fn.id === deferId) fnIndex = index; 95 | }); 96 | 97 | if (fnIndex !== undefined) { 98 | self.deferredFns.splice(fnIndex, 1); 99 | return true; 100 | } 101 | 102 | return false; 103 | }; 104 | 105 | 106 | /** 107 | * @name $browser#defer.flush 108 | * 109 | * @description 110 | * Flushes all pending requests and executes the defer callbacks. 111 | * 112 | * @param {number=} number of milliseconds to flush. See {@link #defer.now} 113 | */ 114 | self.defer.flush = function(delay) { 115 | if (angular.isDefined(delay)) { 116 | self.defer.now += delay; 117 | } else { 118 | if (self.deferredFns.length) { 119 | self.defer.now = self.deferredFns[self.deferredFns.length-1].time; 120 | } else { 121 | throw new Error('No deferred tasks to be flushed'); 122 | } 123 | } 124 | 125 | while (self.deferredFns.length && self.deferredFns[0].time <= self.defer.now) { 126 | self.deferredFns.shift().fn(); 127 | } 128 | }; 129 | 130 | self.$$baseHref = ''; 131 | self.baseHref = function() { 132 | return this.$$baseHref; 133 | }; 134 | }; 135 | angular.mock.$Browser.prototype = { 136 | 137 | /** 138 | * @name $browser#poll 139 | * 140 | * @description 141 | * run all fns in pollFns 142 | */ 143 | poll: function poll() { 144 | angular.forEach(this.pollFns, function(pollFn){ 145 | pollFn(); 146 | }); 147 | }, 148 | 149 | addPollFn: function(pollFn) { 150 | this.pollFns.push(pollFn); 151 | return pollFn; 152 | }, 153 | 154 | url: function(url, replace) { 155 | if (url) { 156 | this.$$url = url; 157 | return this; 158 | } 159 | 160 | return this.$$url; 161 | }, 162 | 163 | cookies: function(name, value) { 164 | if (name) { 165 | if (angular.isUndefined(value)) { 166 | delete this.cookieHash[name]; 167 | } else { 168 | if (angular.isString(value) && //strings only 169 | value.length <= 4096) { //strict cookie storage limits 170 | this.cookieHash[name] = value; 171 | } 172 | } 173 | } else { 174 | if (!angular.equals(this.cookieHash, this.lastCookieHash)) { 175 | this.lastCookieHash = angular.copy(this.cookieHash); 176 | this.cookieHash = angular.copy(this.cookieHash); 177 | } 178 | return this.cookieHash; 179 | } 180 | }, 181 | 182 | notifyWhenNoOutstandingRequests: function(fn) { 183 | fn(); 184 | } 185 | }; 186 | 187 | 188 | /** 189 | * @ngdoc provider 190 | * @name $exceptionHandlerProvider 191 | * 192 | * @description 193 | * Configures the mock implementation of {@link ng.$exceptionHandler} to rethrow or to log errors 194 | * passed into the `$exceptionHandler`. 195 | */ 196 | 197 | /** 198 | * @ngdoc service 199 | * @name $exceptionHandler 200 | * 201 | * @description 202 | * Mock implementation of {@link ng.$exceptionHandler} that rethrows or logs errors passed 203 | * into it. See {@link ngMock.$exceptionHandlerProvider $exceptionHandlerProvider} for configuration 204 | * information. 205 | * 206 | * 207 | * ```js 208 | * describe('$exceptionHandlerProvider', function() { 209 | * 210 | * it('should capture log messages and exceptions', function() { 211 | * 212 | * module(function($exceptionHandlerProvider) { 213 | * $exceptionHandlerProvider.mode('log'); 214 | * }); 215 | * 216 | * inject(function($log, $exceptionHandler, $timeout) { 217 | * $timeout(function() { $log.log(1); }); 218 | * $timeout(function() { $log.log(2); throw 'banana peel'; }); 219 | * $timeout(function() { $log.log(3); }); 220 | * expect($exceptionHandler.errors).toEqual([]); 221 | * expect($log.assertEmpty()); 222 | * $timeout.flush(); 223 | * expect($exceptionHandler.errors).toEqual(['banana peel']); 224 | * expect($log.log.logs).toEqual([[1], [2], [3]]); 225 | * }); 226 | * }); 227 | * }); 228 | * ``` 229 | */ 230 | 231 | angular.mock.$ExceptionHandlerProvider = function() { 232 | var handler; 233 | 234 | /** 235 | * @ngdoc method 236 | * @name $exceptionHandlerProvider#mode 237 | * 238 | * @description 239 | * Sets the logging mode. 240 | * 241 | * @param {string} mode Mode of operation, defaults to `rethrow`. 242 | * 243 | * - `rethrow`: If any errors are passed into the handler in tests, it typically 244 | * means that there is a bug in the application or test, so this mock will 245 | * make these tests fail. 246 | * - `log`: Sometimes it is desirable to test that an error is thrown, for this case the `log` 247 | * mode stores an array of errors in `$exceptionHandler.errors`, to allow later 248 | * assertion of them. See {@link ngMock.$log#assertEmpty assertEmpty()} and 249 | * {@link ngMock.$log#reset reset()} 250 | */ 251 | this.mode = function(mode) { 252 | switch(mode) { 253 | case 'rethrow': 254 | handler = function(e) { 255 | throw e; 256 | }; 257 | break; 258 | case 'log': 259 | var errors = []; 260 | 261 | handler = function(e) { 262 | if (arguments.length == 1) { 263 | errors.push(e); 264 | } else { 265 | errors.push([].slice.call(arguments, 0)); 266 | } 267 | }; 268 | 269 | handler.errors = errors; 270 | break; 271 | default: 272 | throw new Error("Unknown mode '" + mode + "', only 'log'/'rethrow' modes are allowed!"); 273 | } 274 | }; 275 | 276 | this.$get = function() { 277 | return handler; 278 | }; 279 | 280 | this.mode('rethrow'); 281 | }; 282 | 283 | 284 | /** 285 | * @ngdoc service 286 | * @name $log 287 | * 288 | * @description 289 | * Mock implementation of {@link ng.$log} that gathers all logged messages in arrays 290 | * (one array per logging level). These arrays are exposed as `logs` property of each of the 291 | * level-specific log function, e.g. for level `error` the array is exposed as `$log.error.logs`. 292 | * 293 | */ 294 | angular.mock.$LogProvider = function() { 295 | var debug = true; 296 | 297 | function concat(array1, array2, index) { 298 | return array1.concat(Array.prototype.slice.call(array2, index)); 299 | } 300 | 301 | this.debugEnabled = function(flag) { 302 | if (angular.isDefined(flag)) { 303 | debug = flag; 304 | return this; 305 | } else { 306 | return debug; 307 | } 308 | }; 309 | 310 | this.$get = function () { 311 | var $log = { 312 | log: function() { $log.log.logs.push(concat([], arguments, 0)); }, 313 | warn: function() { $log.warn.logs.push(concat([], arguments, 0)); }, 314 | info: function() { $log.info.logs.push(concat([], arguments, 0)); }, 315 | error: function() { $log.error.logs.push(concat([], arguments, 0)); }, 316 | debug: function() { 317 | if (debug) { 318 | $log.debug.logs.push(concat([], arguments, 0)); 319 | } 320 | } 321 | }; 322 | 323 | /** 324 | * @ngdoc method 325 | * @name $log#reset 326 | * 327 | * @description 328 | * Reset all of the logging arrays to empty. 329 | */ 330 | $log.reset = function () { 331 | /** 332 | * @ngdoc property 333 | * @name $log#log.logs 334 | * 335 | * @description 336 | * Array of messages logged using {@link ngMock.$log#log}. 337 | * 338 | * @example 339 | * ```js 340 | * $log.log('Some Log'); 341 | * var first = $log.log.logs.unshift(); 342 | * ``` 343 | */ 344 | $log.log.logs = []; 345 | /** 346 | * @ngdoc property 347 | * @name $log#info.logs 348 | * 349 | * @description 350 | * Array of messages logged using {@link ngMock.$log#info}. 351 | * 352 | * @example 353 | * ```js 354 | * $log.info('Some Info'); 355 | * var first = $log.info.logs.unshift(); 356 | * ``` 357 | */ 358 | $log.info.logs = []; 359 | /** 360 | * @ngdoc property 361 | * @name $log#warn.logs 362 | * 363 | * @description 364 | * Array of messages logged using {@link ngMock.$log#warn}. 365 | * 366 | * @example 367 | * ```js 368 | * $log.warn('Some Warning'); 369 | * var first = $log.warn.logs.unshift(); 370 | * ``` 371 | */ 372 | $log.warn.logs = []; 373 | /** 374 | * @ngdoc property 375 | * @name $log#error.logs 376 | * 377 | * @description 378 | * Array of messages logged using {@link ngMock.$log#error}. 379 | * 380 | * @example 381 | * ```js 382 | * $log.error('Some Error'); 383 | * var first = $log.error.logs.unshift(); 384 | * ``` 385 | */ 386 | $log.error.logs = []; 387 | /** 388 | * @ngdoc property 389 | * @name $log#debug.logs 390 | * 391 | * @description 392 | * Array of messages logged using {@link ngMock.$log#debug}. 393 | * 394 | * @example 395 | * ```js 396 | * $log.debug('Some Error'); 397 | * var first = $log.debug.logs.unshift(); 398 | * ``` 399 | */ 400 | $log.debug.logs = []; 401 | }; 402 | 403 | /** 404 | * @ngdoc method 405 | * @name $log#assertEmpty 406 | * 407 | * @description 408 | * Assert that the all of the logging methods have no logged messages. If messages present, an 409 | * exception is thrown. 410 | */ 411 | $log.assertEmpty = function() { 412 | var errors = []; 413 | angular.forEach(['error', 'warn', 'info', 'log', 'debug'], function(logLevel) { 414 | angular.forEach($log[logLevel].logs, function(log) { 415 | angular.forEach(log, function (logItem) { 416 | errors.push('MOCK $log (' + logLevel + '): ' + String(logItem) + '\n' + 417 | (logItem.stack || '')); 418 | }); 419 | }); 420 | }); 421 | if (errors.length) { 422 | errors.unshift("Expected $log to be empty! Either a message was logged unexpectedly, or "+ 423 | "an expected log message was not checked and removed:"); 424 | errors.push(''); 425 | throw new Error(errors.join('\n---------\n')); 426 | } 427 | }; 428 | 429 | $log.reset(); 430 | return $log; 431 | }; 432 | }; 433 | 434 | 435 | /** 436 | * @ngdoc service 437 | * @name $interval 438 | * 439 | * @description 440 | * Mock implementation of the $interval service. 441 | * 442 | * Use {@link ngMock.$interval#flush `$interval.flush(millis)`} to 443 | * move forward by `millis` milliseconds and trigger any functions scheduled to run in that 444 | * time. 445 | * 446 | * @param {function()} fn A function that should be called repeatedly. 447 | * @param {number} delay Number of milliseconds between each function call. 448 | * @param {number=} [count=0] Number of times to repeat. If not set, or 0, will repeat 449 | * indefinitely. 450 | * @param {boolean=} [invokeApply=true] If set to `false` skips model dirty checking, otherwise 451 | * will invoke `fn` within the {@link ng.$rootScope.Scope#$apply $apply} block. 452 | * @returns {promise} A promise which will be notified on each iteration. 453 | */ 454 | angular.mock.$IntervalProvider = function() { 455 | this.$get = ['$rootScope', '$q', 456 | function($rootScope, $q) { 457 | var repeatFns = [], 458 | nextRepeatId = 0, 459 | now = 0; 460 | 461 | var $interval = function(fn, delay, count, invokeApply) { 462 | var deferred = $q.defer(), 463 | promise = deferred.promise, 464 | iteration = 0, 465 | skipApply = (angular.isDefined(invokeApply) && !invokeApply); 466 | 467 | count = (angular.isDefined(count)) ? count : 0; 468 | promise.then(null, null, fn); 469 | 470 | promise.$$intervalId = nextRepeatId; 471 | 472 | function tick() { 473 | deferred.notify(iteration++); 474 | 475 | if (count > 0 && iteration >= count) { 476 | var fnIndex; 477 | deferred.resolve(iteration); 478 | 479 | angular.forEach(repeatFns, function(fn, index) { 480 | if (fn.id === promise.$$intervalId) fnIndex = index; 481 | }); 482 | 483 | if (fnIndex !== undefined) { 484 | repeatFns.splice(fnIndex, 1); 485 | } 486 | } 487 | 488 | if (!skipApply) $rootScope.$apply(); 489 | } 490 | 491 | repeatFns.push({ 492 | nextTime:(now + delay), 493 | delay: delay, 494 | fn: tick, 495 | id: nextRepeatId, 496 | deferred: deferred 497 | }); 498 | repeatFns.sort(function(a,b){ return a.nextTime - b.nextTime;}); 499 | 500 | nextRepeatId++; 501 | return promise; 502 | }; 503 | /** 504 | * @ngdoc method 505 | * @name $interval#cancel 506 | * 507 | * @description 508 | * Cancels a task associated with the `promise`. 509 | * 510 | * @param {promise} promise A promise from calling the `$interval` function. 511 | * @returns {boolean} Returns `true` if the task was successfully cancelled. 512 | */ 513 | $interval.cancel = function(promise) { 514 | if(!promise) return false; 515 | var fnIndex; 516 | 517 | angular.forEach(repeatFns, function(fn, index) { 518 | if (fn.id === promise.$$intervalId) fnIndex = index; 519 | }); 520 | 521 | if (fnIndex !== undefined) { 522 | repeatFns[fnIndex].deferred.reject('canceled'); 523 | repeatFns.splice(fnIndex, 1); 524 | return true; 525 | } 526 | 527 | return false; 528 | }; 529 | 530 | /** 531 | * @ngdoc method 532 | * @name $interval#flush 533 | * @description 534 | * 535 | * Runs interval tasks scheduled to be run in the next `millis` milliseconds. 536 | * 537 | * @param {number=} millis maximum timeout amount to flush up until. 538 | * 539 | * @return {number} The amount of time moved forward. 540 | */ 541 | $interval.flush = function(millis) { 542 | now += millis; 543 | while (repeatFns.length && repeatFns[0].nextTime <= now) { 544 | var task = repeatFns[0]; 545 | task.fn(); 546 | task.nextTime += task.delay; 547 | repeatFns.sort(function(a,b){ return a.nextTime - b.nextTime;}); 548 | } 549 | return millis; 550 | }; 551 | 552 | return $interval; 553 | }]; 554 | }; 555 | 556 | 557 | /* jshint -W101 */ 558 | /* The R_ISO8061_STR regex is never going to fit into the 100 char limit! 559 | * This directive should go inside the anonymous function but a bug in JSHint means that it would 560 | * not be enacted early enough to prevent the warning. 561 | */ 562 | var R_ISO8061_STR = /^(\d{4})-?(\d\d)-?(\d\d)(?:T(\d\d)(?:\:?(\d\d)(?:\:?(\d\d)(?:\.(\d{3}))?)?)?(Z|([+-])(\d\d):?(\d\d)))?$/; 563 | 564 | function jsonStringToDate(string) { 565 | var match; 566 | if (match = string.match(R_ISO8061_STR)) { 567 | var date = new Date(0), 568 | tzHour = 0, 569 | tzMin = 0; 570 | if (match[9]) { 571 | tzHour = int(match[9] + match[10]); 572 | tzMin = int(match[9] + match[11]); 573 | } 574 | date.setUTCFullYear(int(match[1]), int(match[2]) - 1, int(match[3])); 575 | date.setUTCHours(int(match[4]||0) - tzHour, 576 | int(match[5]||0) - tzMin, 577 | int(match[6]||0), 578 | int(match[7]||0)); 579 | return date; 580 | } 581 | return string; 582 | } 583 | 584 | function int(str) { 585 | return parseInt(str, 10); 586 | } 587 | 588 | function padNumber(num, digits, trim) { 589 | var neg = ''; 590 | if (num < 0) { 591 | neg = '-'; 592 | num = -num; 593 | } 594 | num = '' + num; 595 | while(num.length < digits) num = '0' + num; 596 | if (trim) 597 | num = num.substr(num.length - digits); 598 | return neg + num; 599 | } 600 | 601 | 602 | /** 603 | * @ngdoc type 604 | * @name angular.mock.TzDate 605 | * @description 606 | * 607 | * *NOTE*: this is not an injectable instance, just a globally available mock class of `Date`. 608 | * 609 | * Mock of the Date type which has its timezone specified via constructor arg. 610 | * 611 | * The main purpose is to create Date-like instances with timezone fixed to the specified timezone 612 | * offset, so that we can test code that depends on local timezone settings without dependency on 613 | * the time zone settings of the machine where the code is running. 614 | * 615 | * @param {number} offset Offset of the *desired* timezone in hours (fractions will be honored) 616 | * @param {(number|string)} timestamp Timestamp representing the desired time in *UTC* 617 | * 618 | * @example 619 | * !!!! WARNING !!!!! 620 | * This is not a complete Date object so only methods that were implemented can be called safely. 621 | * To make matters worse, TzDate instances inherit stuff from Date via a prototype. 622 | * 623 | * We do our best to intercept calls to "unimplemented" methods, but since the list of methods is 624 | * incomplete we might be missing some non-standard methods. This can result in errors like: 625 | * "Date.prototype.foo called on incompatible Object". 626 | * 627 | * ```js 628 | * var newYearInBratislava = new TzDate(-1, '2009-12-31T23:00:00Z'); 629 | * newYearInBratislava.getTimezoneOffset() => -60; 630 | * newYearInBratislava.getFullYear() => 2010; 631 | * newYearInBratislava.getMonth() => 0; 632 | * newYearInBratislava.getDate() => 1; 633 | * newYearInBratislava.getHours() => 0; 634 | * newYearInBratislava.getMinutes() => 0; 635 | * newYearInBratislava.getSeconds() => 0; 636 | * ``` 637 | * 638 | */ 639 | angular.mock.TzDate = function (offset, timestamp) { 640 | var self = new Date(0); 641 | if (angular.isString(timestamp)) { 642 | var tsStr = timestamp; 643 | 644 | self.origDate = jsonStringToDate(timestamp); 645 | 646 | timestamp = self.origDate.getTime(); 647 | if (isNaN(timestamp)) 648 | throw { 649 | name: "Illegal Argument", 650 | message: "Arg '" + tsStr + "' passed into TzDate constructor is not a valid date string" 651 | }; 652 | } else { 653 | self.origDate = new Date(timestamp); 654 | } 655 | 656 | var localOffset = new Date(timestamp).getTimezoneOffset(); 657 | self.offsetDiff = localOffset*60*1000 - offset*1000*60*60; 658 | self.date = new Date(timestamp + self.offsetDiff); 659 | 660 | self.getTime = function() { 661 | return self.date.getTime() - self.offsetDiff; 662 | }; 663 | 664 | self.toLocaleDateString = function() { 665 | return self.date.toLocaleDateString(); 666 | }; 667 | 668 | self.getFullYear = function() { 669 | return self.date.getFullYear(); 670 | }; 671 | 672 | self.getMonth = function() { 673 | return self.date.getMonth(); 674 | }; 675 | 676 | self.getDate = function() { 677 | return self.date.getDate(); 678 | }; 679 | 680 | self.getHours = function() { 681 | return self.date.getHours(); 682 | }; 683 | 684 | self.getMinutes = function() { 685 | return self.date.getMinutes(); 686 | }; 687 | 688 | self.getSeconds = function() { 689 | return self.date.getSeconds(); 690 | }; 691 | 692 | self.getMilliseconds = function() { 693 | return self.date.getMilliseconds(); 694 | }; 695 | 696 | self.getTimezoneOffset = function() { 697 | return offset * 60; 698 | }; 699 | 700 | self.getUTCFullYear = function() { 701 | return self.origDate.getUTCFullYear(); 702 | }; 703 | 704 | self.getUTCMonth = function() { 705 | return self.origDate.getUTCMonth(); 706 | }; 707 | 708 | self.getUTCDate = function() { 709 | return self.origDate.getUTCDate(); 710 | }; 711 | 712 | self.getUTCHours = function() { 713 | return self.origDate.getUTCHours(); 714 | }; 715 | 716 | self.getUTCMinutes = function() { 717 | return self.origDate.getUTCMinutes(); 718 | }; 719 | 720 | self.getUTCSeconds = function() { 721 | return self.origDate.getUTCSeconds(); 722 | }; 723 | 724 | self.getUTCMilliseconds = function() { 725 | return self.origDate.getUTCMilliseconds(); 726 | }; 727 | 728 | self.getDay = function() { 729 | return self.date.getDay(); 730 | }; 731 | 732 | // provide this method only on browsers that already have it 733 | if (self.toISOString) { 734 | self.toISOString = function() { 735 | return padNumber(self.origDate.getUTCFullYear(), 4) + '-' + 736 | padNumber(self.origDate.getUTCMonth() + 1, 2) + '-' + 737 | padNumber(self.origDate.getUTCDate(), 2) + 'T' + 738 | padNumber(self.origDate.getUTCHours(), 2) + ':' + 739 | padNumber(self.origDate.getUTCMinutes(), 2) + ':' + 740 | padNumber(self.origDate.getUTCSeconds(), 2) + '.' + 741 | padNumber(self.origDate.getUTCMilliseconds(), 3) + 'Z'; 742 | }; 743 | } 744 | 745 | //hide all methods not implemented in this mock that the Date prototype exposes 746 | var unimplementedMethods = ['getUTCDay', 747 | 'getYear', 'setDate', 'setFullYear', 'setHours', 'setMilliseconds', 748 | 'setMinutes', 'setMonth', 'setSeconds', 'setTime', 'setUTCDate', 'setUTCFullYear', 749 | 'setUTCHours', 'setUTCMilliseconds', 'setUTCMinutes', 'setUTCMonth', 'setUTCSeconds', 750 | 'setYear', 'toDateString', 'toGMTString', 'toJSON', 'toLocaleFormat', 'toLocaleString', 751 | 'toLocaleTimeString', 'toSource', 'toString', 'toTimeString', 'toUTCString', 'valueOf']; 752 | 753 | angular.forEach(unimplementedMethods, function(methodName) { 754 | self[methodName] = function() { 755 | throw new Error("Method '" + methodName + "' is not implemented in the TzDate mock"); 756 | }; 757 | }); 758 | 759 | return self; 760 | }; 761 | 762 | //make "tzDateInstance instanceof Date" return true 763 | angular.mock.TzDate.prototype = Date.prototype; 764 | /* jshint +W101 */ 765 | 766 | angular.mock.animate = angular.module('ngAnimateMock', ['ng']) 767 | 768 | .config(['$provide', function($provide) { 769 | 770 | var reflowQueue = []; 771 | $provide.value('$$animateReflow', function(fn) { 772 | var index = reflowQueue.length; 773 | reflowQueue.push(fn); 774 | return function cancel() { 775 | reflowQueue.splice(index, 1); 776 | }; 777 | }); 778 | 779 | $provide.decorator('$animate', function($delegate, $$asyncCallback) { 780 | var animate = { 781 | queue : [], 782 | enabled : $delegate.enabled, 783 | triggerCallbacks : function() { 784 | $$asyncCallback.flush(); 785 | }, 786 | triggerReflow : function() { 787 | angular.forEach(reflowQueue, function(fn) { 788 | fn(); 789 | }); 790 | reflowQueue = []; 791 | } 792 | }; 793 | 794 | angular.forEach( 795 | ['enter','leave','move','addClass','removeClass','setClass'], function(method) { 796 | animate[method] = function() { 797 | animate.queue.push({ 798 | event : method, 799 | element : arguments[0], 800 | args : arguments 801 | }); 802 | $delegate[method].apply($delegate, arguments); 803 | }; 804 | }); 805 | 806 | return animate; 807 | }); 808 | 809 | }]); 810 | 811 | 812 | /** 813 | * @ngdoc function 814 | * @name angular.mock.dump 815 | * @description 816 | * 817 | * *NOTE*: this is not an injectable instance, just a globally available function. 818 | * 819 | * Method for serializing common angular objects (scope, elements, etc..) into strings, useful for 820 | * debugging. 821 | * 822 | * This method is also available on window, where it can be used to display objects on debug 823 | * console. 824 | * 825 | * @param {*} object - any object to turn into string. 826 | * @return {string} a serialized string of the argument 827 | */ 828 | angular.mock.dump = function(object) { 829 | return serialize(object); 830 | 831 | function serialize(object) { 832 | var out; 833 | 834 | if (angular.isElement(object)) { 835 | object = angular.element(object); 836 | out = angular.element('
'); 837 | angular.forEach(object, function(element) { 838 | out.append(angular.element(element).clone()); 839 | }); 840 | out = out.html(); 841 | } else if (angular.isArray(object)) { 842 | out = []; 843 | angular.forEach(object, function(o) { 844 | out.push(serialize(o)); 845 | }); 846 | out = '[ ' + out.join(', ') + ' ]'; 847 | } else if (angular.isObject(object)) { 848 | if (angular.isFunction(object.$eval) && angular.isFunction(object.$apply)) { 849 | out = serializeScope(object); 850 | } else if (object instanceof Error) { 851 | out = object.stack || ('' + object.name + ': ' + object.message); 852 | } else { 853 | // TODO(i): this prevents methods being logged, 854 | // we should have a better way to serialize objects 855 | out = angular.toJson(object, true); 856 | } 857 | } else { 858 | out = String(object); 859 | } 860 | 861 | return out; 862 | } 863 | 864 | function serializeScope(scope, offset) { 865 | offset = offset || ' '; 866 | var log = [offset + 'Scope(' + scope.$id + '): {']; 867 | for ( var key in scope ) { 868 | if (Object.prototype.hasOwnProperty.call(scope, key) && !key.match(/^(\$|this)/)) { 869 | log.push(' ' + key + ': ' + angular.toJson(scope[key])); 870 | } 871 | } 872 | var child = scope.$$childHead; 873 | while(child) { 874 | log.push(serializeScope(child, offset + ' ')); 875 | child = child.$$nextSibling; 876 | } 877 | log.push('}'); 878 | return log.join('\n' + offset); 879 | } 880 | }; 881 | 882 | /** 883 | * @ngdoc service 884 | * @name $httpBackend 885 | * @description 886 | * Fake HTTP backend implementation suitable for unit testing applications that use the 887 | * {@link ng.$http $http service}. 888 | * 889 | * *Note*: For fake HTTP backend implementation suitable for end-to-end testing or backend-less 890 | * development please see {@link ngMockE2E.$httpBackend e2e $httpBackend mock}. 891 | * 892 | * During unit testing, we want our unit tests to run quickly and have no external dependencies so 893 | * we don’t want to send [XHR](https://developer.mozilla.org/en/xmlhttprequest) or 894 | * [JSONP](http://en.wikipedia.org/wiki/JSONP) requests to a real server. All we really need is 895 | * to verify whether a certain request has been sent or not, or alternatively just let the 896 | * application make requests, respond with pre-trained responses and assert that the end result is 897 | * what we expect it to be. 898 | * 899 | * This mock implementation can be used to respond with static or dynamic responses via the 900 | * `expect` and `when` apis and their shortcuts (`expectGET`, `whenPOST`, etc). 901 | * 902 | * When an Angular application needs some data from a server, it calls the $http service, which 903 | * sends the request to a real server using $httpBackend service. With dependency injection, it is 904 | * easy to inject $httpBackend mock (which has the same API as $httpBackend) and use it to verify 905 | * the requests and respond with some testing data without sending a request to a real server. 906 | * 907 | * There are two ways to specify what test data should be returned as http responses by the mock 908 | * backend when the code under test makes http requests: 909 | * 910 | * - `$httpBackend.expect` - specifies a request expectation 911 | * - `$httpBackend.when` - specifies a backend definition 912 | * 913 | * 914 | * # Request Expectations vs Backend Definitions 915 | * 916 | * Request expectations provide a way to make assertions about requests made by the application and 917 | * to define responses for those requests. The test will fail if the expected requests are not made 918 | * or they are made in the wrong order. 919 | * 920 | * Backend definitions allow you to define a fake backend for your application which doesn't assert 921 | * if a particular request was made or not, it just returns a trained response if a request is made. 922 | * The test will pass whether or not the request gets made during testing. 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 | * 956 | * 957 | *
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
958 | * 959 | * In cases where both backend definitions and request expectations are specified during unit 960 | * testing, the request expectations are evaluated first. 961 | * 962 | * If a request expectation has no response specified, the algorithm will search your backend 963 | * definitions for an appropriate response. 964 | * 965 | * If a request didn't match any expectation or if the expectation doesn't have the response 966 | * defined, the backend definitions are evaluated in sequential order to see if any of them match 967 | * the request. The response from the first matched definition is returned. 968 | * 969 | * 970 | * # Flushing HTTP requests 971 | * 972 | * The $httpBackend used in production always responds to requests asynchronously. If we preserved 973 | * this behavior in unit testing, we'd have to create async unit tests, which are hard to write, 974 | * to follow and to maintain. But neither can the testing mock respond synchronously; that would 975 | * change the execution of the code under test. For this reason, the mock $httpBackend has a 976 | * `flush()` method, which allows the test to explicitly flush pending requests. This preserves 977 | * the async api of the backend, while allowing the test to execute synchronously. 978 | * 979 | * 980 | * # Unit testing with mock $httpBackend 981 | * The following code shows how to setup and use the mock backend when unit testing a controller. 982 | * First we create the controller under test: 983 | * 984 | ```js 985 | // The controller code 986 | function MyController($scope, $http) { 987 | var authToken; 988 | 989 | $http.get('/auth.py').success(function(data, status, headers) { 990 | authToken = headers('A-Token'); 991 | $scope.user = data; 992 | }); 993 | 994 | $scope.saveMessage = function(message) { 995 | var headers = { 'Authorization': authToken }; 996 | $scope.status = 'Saving...'; 997 | 998 | $http.post('/add-msg.py', message, { headers: headers } ).success(function(response) { 999 | $scope.status = ''; 1000 | }).error(function() { 1001 | $scope.status = 'ERROR!'; 1002 | }); 1003 | }; 1004 | } 1005 | ``` 1006 | * 1007 | * Now we setup the mock backend and create the test specs: 1008 | * 1009 | ```js 1010 | // testing controller 1011 | describe('MyController', function() { 1012 | var $httpBackend, $rootScope, createController; 1013 | 1014 | beforeEach(inject(function($injector) { 1015 | // Set up the mock http service responses 1016 | $httpBackend = $injector.get('$httpBackend'); 1017 | // backend definition common for all tests 1018 | $httpBackend.when('GET', '/auth.py').respond({userId: 'userX'}, {'A-Token': 'xxx'}); 1019 | 1020 | // Get hold of a scope (i.e. the root scope) 1021 | $rootScope = $injector.get('$rootScope'); 1022 | // The $controller service is used to create instances of controllers 1023 | var $controller = $injector.get('$controller'); 1024 | 1025 | createController = function() { 1026 | return $controller('MyController', {'$scope' : $rootScope }); 1027 | }; 1028 | })); 1029 | 1030 | 1031 | afterEach(function() { 1032 | $httpBackend.verifyNoOutstandingExpectation(); 1033 | $httpBackend.verifyNoOutstandingRequest(); 1034 | }); 1035 | 1036 | 1037 | it('should fetch authentication token', function() { 1038 | $httpBackend.expectGET('/auth.py'); 1039 | var controller = createController(); 1040 | $httpBackend.flush(); 1041 | }); 1042 | 1043 | 1044 | it('should send msg to server', function() { 1045 | var controller = createController(); 1046 | $httpBackend.flush(); 1047 | 1048 | // now you don’t care about the authentication, but 1049 | // the controller will still send the request and 1050 | // $httpBackend will respond without you having to 1051 | // specify the expectation and response for this request 1052 | 1053 | $httpBackend.expectPOST('/add-msg.py', 'message content').respond(201, ''); 1054 | $rootScope.saveMessage('message content'); 1055 | expect($rootScope.status).toBe('Saving...'); 1056 | $httpBackend.flush(); 1057 | expect($rootScope.status).toBe(''); 1058 | }); 1059 | 1060 | 1061 | it('should send auth header', function() { 1062 | var controller = createController(); 1063 | $httpBackend.flush(); 1064 | 1065 | $httpBackend.expectPOST('/add-msg.py', undefined, function(headers) { 1066 | // check if the header was send, if it wasn't the expectation won't 1067 | // match the request and the test will fail 1068 | return headers['Authorization'] == 'xxx'; 1069 | }).respond(201, ''); 1070 | 1071 | $rootScope.saveMessage('whatever'); 1072 | $httpBackend.flush(); 1073 | }); 1074 | }); 1075 | ``` 1076 | */ 1077 | angular.mock.$HttpBackendProvider = function() { 1078 | this.$get = ['$rootScope', createHttpBackendMock]; 1079 | }; 1080 | 1081 | /** 1082 | * General factory function for $httpBackend mock. 1083 | * Returns instance for unit testing (when no arguments specified): 1084 | * - passing through is disabled 1085 | * - auto flushing is disabled 1086 | * 1087 | * Returns instance for e2e testing (when `$delegate` and `$browser` specified): 1088 | * - passing through (delegating request to real backend) is enabled 1089 | * - auto flushing is enabled 1090 | * 1091 | * @param {Object=} $delegate Real $httpBackend instance (allow passing through if specified) 1092 | * @param {Object=} $browser Auto-flushing enabled if specified 1093 | * @return {Object} Instance of $httpBackend mock 1094 | */ 1095 | function createHttpBackendMock($rootScope, $delegate, $browser) { 1096 | var definitions = [], 1097 | expectations = [], 1098 | responses = [], 1099 | responsesPush = angular.bind(responses, responses.push), 1100 | copy = angular.copy; 1101 | 1102 | function createResponse(status, data, headers, statusText) { 1103 | if (angular.isFunction(status)) return status; 1104 | 1105 | return function() { 1106 | return angular.isNumber(status) 1107 | ? [status, data, headers, statusText] 1108 | : [200, status, data]; 1109 | }; 1110 | } 1111 | 1112 | // TODO(vojta): change params to: method, url, data, headers, callback 1113 | function $httpBackend(method, url, data, callback, headers, timeout, withCredentials) { 1114 | var xhr = new MockXhr(), 1115 | expectation = expectations[0], 1116 | wasExpected = false; 1117 | 1118 | function prettyPrint(data) { 1119 | return (angular.isString(data) || angular.isFunction(data) || data instanceof RegExp) 1120 | ? data 1121 | : angular.toJson(data); 1122 | } 1123 | 1124 | function wrapResponse(wrapped) { 1125 | if (!$browser && timeout && timeout.then) timeout.then(handleTimeout); 1126 | 1127 | return handleResponse; 1128 | 1129 | function handleResponse() { 1130 | var response = wrapped.response(method, url, data, headers); 1131 | xhr.$$respHeaders = response[2]; 1132 | callback(copy(response[0]), copy(response[1]), xhr.getAllResponseHeaders(), 1133 | copy(response[3] || '')); 1134 | } 1135 | 1136 | function handleTimeout() { 1137 | for (var i = 0, ii = responses.length; i < ii; i++) { 1138 | if (responses[i] === handleResponse) { 1139 | responses.splice(i, 1); 1140 | callback(-1, undefined, ''); 1141 | break; 1142 | } 1143 | } 1144 | } 1145 | } 1146 | 1147 | if (expectation && expectation.match(method, url)) { 1148 | if (!expectation.matchData(data)) 1149 | throw new Error('Expected ' + expectation + ' with different data\n' + 1150 | 'EXPECTED: ' + prettyPrint(expectation.data) + '\nGOT: ' + data); 1151 | 1152 | if (!expectation.matchHeaders(headers)) 1153 | throw new Error('Expected ' + expectation + ' with different headers\n' + 1154 | 'EXPECTED: ' + prettyPrint(expectation.headers) + '\nGOT: ' + 1155 | prettyPrint(headers)); 1156 | 1157 | expectations.shift(); 1158 | 1159 | if (expectation.response) { 1160 | responses.push(wrapResponse(expectation)); 1161 | return; 1162 | } 1163 | wasExpected = true; 1164 | } 1165 | 1166 | var i = -1, definition; 1167 | while ((definition = definitions[++i])) { 1168 | if (definition.match(method, url, data, headers || {})) { 1169 | if (definition.response) { 1170 | // if $browser specified, we do auto flush all requests 1171 | ($browser ? $browser.defer : responsesPush)(wrapResponse(definition)); 1172 | } else if (definition.passThrough) { 1173 | $delegate(method, url, data, callback, headers, timeout, withCredentials); 1174 | } else throw new Error('No response defined !'); 1175 | return; 1176 | } 1177 | } 1178 | throw wasExpected ? 1179 | new Error('No response defined !') : 1180 | new Error('Unexpected request: ' + method + ' ' + url + '\n' + 1181 | (expectation ? 'Expected ' + expectation : 'No more request expected')); 1182 | } 1183 | 1184 | /** 1185 | * @ngdoc method 1186 | * @name $httpBackend#when 1187 | * @description 1188 | * Creates a new backend definition. 1189 | * 1190 | * @param {string} method HTTP method. 1191 | * @param {string|RegExp} url HTTP url. 1192 | * @param {(string|RegExp|function(string))=} data HTTP request body or function that receives 1193 | * data string and returns true if the data is as expected. 1194 | * @param {(Object|function(Object))=} headers HTTP headers or function that receives http header 1195 | * object and returns true if the headers match the current definition. 1196 | * @returns {requestHandler} Returns an object with a `respond` method that controls how a matched 1197 | * request is handled. 1198 | * 1199 | * - respond – 1200 | * `{function([status,] data[, headers, statusText]) 1201 | * | function(function(method, url, data, headers)}` 1202 | * – The respond method takes a set of static data to be returned or a function that can 1203 | * return an array containing response status (number), response data (string), response 1204 | * headers (Object), and the text for the status (string). 1205 | */ 1206 | $httpBackend.when = function(method, url, data, headers) { 1207 | var definition = new MockHttpExpectation(method, url, data, headers), 1208 | chain = { 1209 | respond: function(status, data, headers, statusText) { 1210 | definition.response = createResponse(status, data, headers, statusText); 1211 | } 1212 | }; 1213 | 1214 | if ($browser) { 1215 | chain.passThrough = function() { 1216 | definition.passThrough = true; 1217 | }; 1218 | } 1219 | 1220 | definitions.push(definition); 1221 | return chain; 1222 | }; 1223 | 1224 | /** 1225 | * @ngdoc method 1226 | * @name $httpBackend#whenGET 1227 | * @description 1228 | * Creates a new backend definition for GET requests. For more info see `when()`. 1229 | * 1230 | * @param {string|RegExp} url HTTP url. 1231 | * @param {(Object|function(Object))=} headers HTTP headers. 1232 | * @returns {requestHandler} Returns an object with a `respond` method that controls how a matched 1233 | * request is handled. 1234 | */ 1235 | 1236 | /** 1237 | * @ngdoc method 1238 | * @name $httpBackend#whenHEAD 1239 | * @description 1240 | * Creates a new backend definition for HEAD requests. For more info see `when()`. 1241 | * 1242 | * @param {string|RegExp} url HTTP url. 1243 | * @param {(Object|function(Object))=} headers HTTP headers. 1244 | * @returns {requestHandler} Returns an object with a `respond` method that controls how a matched 1245 | * request is handled. 1246 | */ 1247 | 1248 | /** 1249 | * @ngdoc method 1250 | * @name $httpBackend#whenDELETE 1251 | * @description 1252 | * Creates a new backend definition for DELETE requests. For more info see `when()`. 1253 | * 1254 | * @param {string|RegExp} url HTTP url. 1255 | * @param {(Object|function(Object))=} headers HTTP headers. 1256 | * @returns {requestHandler} Returns an object with a `respond` method that controls how a matched 1257 | * request is handled. 1258 | */ 1259 | 1260 | /** 1261 | * @ngdoc method 1262 | * @name $httpBackend#whenPOST 1263 | * @description 1264 | * Creates a new backend definition for POST requests. For more info see `when()`. 1265 | * 1266 | * @param {string|RegExp} url HTTP url. 1267 | * @param {(string|RegExp|function(string))=} data HTTP request body or function that receives 1268 | * data string and returns true if the data is as expected. 1269 | * @param {(Object|function(Object))=} headers HTTP headers. 1270 | * @returns {requestHandler} Returns an object with a `respond` method that controls how a matched 1271 | * request is handled. 1272 | */ 1273 | 1274 | /** 1275 | * @ngdoc method 1276 | * @name $httpBackend#whenPUT 1277 | * @description 1278 | * Creates a new backend definition for PUT requests. For more info see `when()`. 1279 | * 1280 | * @param {string|RegExp} url HTTP url. 1281 | * @param {(string|RegExp|function(string))=} data HTTP request body or function that receives 1282 | * data string and returns true if the data is as expected. 1283 | * @param {(Object|function(Object))=} headers HTTP headers. 1284 | * @returns {requestHandler} Returns an object with a `respond` method that controls how a matched 1285 | * request is handled. 1286 | */ 1287 | 1288 | /** 1289 | * @ngdoc method 1290 | * @name $httpBackend#whenPATCH 1291 | * @description 1292 | * Creates a new backend definition for PATCH requests. For more info see `when()`. 1293 | * 1294 | * @param {string|RegExp} url HTTP url. 1295 | * @param {(string|RegExp|function(string))=} data HTTP request body or function that receives 1296 | * data string and returns true if the data is as expected. 1297 | * @param {(Object|function(Object))=} headers HTTP headers. 1298 | * @returns {requestHandler} Returns an object with a `respond` method that controls how a matched 1299 | * request is handled. 1300 | */ 1301 | 1302 | /** 1303 | * @ngdoc method 1304 | * @name $httpBackend#whenJSONP 1305 | * @description 1306 | * Creates a new backend definition for JSONP requests. For more info see `when()`. 1307 | * 1308 | * @param {string|RegExp} url HTTP url. 1309 | * @returns {requestHandler} Returns an object with a `respond` method that controls how a matched 1310 | * request is handled. 1311 | */ 1312 | createShortMethods('when'); 1313 | 1314 | 1315 | /** 1316 | * @ngdoc method 1317 | * @name $httpBackend#expect 1318 | * @description 1319 | * Creates a new request expectation. 1320 | * 1321 | * @param {string} method HTTP method. 1322 | * @param {string|RegExp} url HTTP url. 1323 | * @param {(string|RegExp|function(string)|Object)=} data HTTP request body or function that 1324 | * receives data string and returns true if the data is as expected, or Object if request body 1325 | * is in JSON format. 1326 | * @param {(Object|function(Object))=} headers HTTP headers or function that receives http header 1327 | * object and returns true if the headers match the current expectation. 1328 | * @returns {requestHandler} Returns an object with a `respond` method that controls how a matched 1329 | * request is handled. 1330 | * 1331 | * - respond – 1332 | * `{function([status,] data[, headers, statusText]) 1333 | * | function(function(method, url, data, headers)}` 1334 | * – The respond method takes a set of static data to be returned or a function that can 1335 | * return an array containing response status (number), response data (string), response 1336 | * headers (Object), and the text for the status (string). 1337 | */ 1338 | $httpBackend.expect = function(method, url, data, headers) { 1339 | var expectation = new MockHttpExpectation(method, url, data, headers); 1340 | expectations.push(expectation); 1341 | return { 1342 | respond: function (status, data, headers, statusText) { 1343 | expectation.response = createResponse(status, data, headers, statusText); 1344 | } 1345 | }; 1346 | }; 1347 | 1348 | 1349 | /** 1350 | * @ngdoc method 1351 | * @name $httpBackend#expectGET 1352 | * @description 1353 | * Creates a new request expectation for GET requests. For more info see `expect()`. 1354 | * 1355 | * @param {string|RegExp} url HTTP url. 1356 | * @param {Object=} headers HTTP headers. 1357 | * @returns {requestHandler} Returns an object with a `respond` method that controls how a matched 1358 | * request is handled. See #expect for more info. 1359 | */ 1360 | 1361 | /** 1362 | * @ngdoc method 1363 | * @name $httpBackend#expectHEAD 1364 | * @description 1365 | * Creates a new request expectation for HEAD requests. For more info see `expect()`. 1366 | * 1367 | * @param {string|RegExp} url HTTP url. 1368 | * @param {Object=} headers HTTP headers. 1369 | * @returns {requestHandler} Returns an object with a `respond` method that controls how a matched 1370 | * request is handled. 1371 | */ 1372 | 1373 | /** 1374 | * @ngdoc method 1375 | * @name $httpBackend#expectDELETE 1376 | * @description 1377 | * Creates a new request expectation for DELETE requests. For more info see `expect()`. 1378 | * 1379 | * @param {string|RegExp} url HTTP url. 1380 | * @param {Object=} headers HTTP headers. 1381 | * @returns {requestHandler} Returns an object with a `respond` method that controls how a matched 1382 | * request is handled. 1383 | */ 1384 | 1385 | /** 1386 | * @ngdoc method 1387 | * @name $httpBackend#expectPOST 1388 | * @description 1389 | * Creates a new request expectation for POST requests. For more info see `expect()`. 1390 | * 1391 | * @param {string|RegExp} url HTTP url. 1392 | * @param {(string|RegExp|function(string)|Object)=} data HTTP request body or function that 1393 | * receives data string and returns true if the data is as expected, or Object if request body 1394 | * is in JSON format. 1395 | * @param {Object=} headers HTTP headers. 1396 | * @returns {requestHandler} Returns an object with a `respond` method that controls how a matched 1397 | * request is handled. 1398 | */ 1399 | 1400 | /** 1401 | * @ngdoc method 1402 | * @name $httpBackend#expectPUT 1403 | * @description 1404 | * Creates a new request expectation for PUT requests. For more info see `expect()`. 1405 | * 1406 | * @param {string|RegExp} url HTTP url. 1407 | * @param {(string|RegExp|function(string)|Object)=} data HTTP request body or function that 1408 | * receives data string and returns true if the data is as expected, or Object if request body 1409 | * is in JSON format. 1410 | * @param {Object=} headers HTTP headers. 1411 | * @returns {requestHandler} Returns an object with a `respond` method that controls how a matched 1412 | * request is handled. 1413 | */ 1414 | 1415 | /** 1416 | * @ngdoc method 1417 | * @name $httpBackend#expectPATCH 1418 | * @description 1419 | * Creates a new request expectation for PATCH requests. For more info see `expect()`. 1420 | * 1421 | * @param {string|RegExp} url HTTP url. 1422 | * @param {(string|RegExp|function(string)|Object)=} data HTTP request body or function that 1423 | * receives data string and returns true if the data is as expected, or Object if request body 1424 | * is in JSON format. 1425 | * @param {Object=} headers HTTP headers. 1426 | * @returns {requestHandler} Returns an object with a `respond` method that controls how a matched 1427 | * request is handled. 1428 | */ 1429 | 1430 | /** 1431 | * @ngdoc method 1432 | * @name $httpBackend#expectJSONP 1433 | * @description 1434 | * Creates a new request expectation for JSONP requests. For more info see `expect()`. 1435 | * 1436 | * @param {string|RegExp} url HTTP url. 1437 | * @returns {requestHandler} Returns an object with a `respond` method that controls how a matched 1438 | * request is handled. 1439 | */ 1440 | createShortMethods('expect'); 1441 | 1442 | 1443 | /** 1444 | * @ngdoc method 1445 | * @name $httpBackend#flush 1446 | * @description 1447 | * Flushes all pending requests using the trained responses. 1448 | * 1449 | * @param {number=} count Number of responses to flush (in the order they arrived). If undefined, 1450 | * all pending requests will be flushed. If there are no pending requests when the flush method 1451 | * is called an exception is thrown (as this typically a sign of programming error). 1452 | */ 1453 | $httpBackend.flush = function(count) { 1454 | $rootScope.$digest(); 1455 | if (!responses.length) throw new Error('No pending request to flush !'); 1456 | 1457 | if (angular.isDefined(count)) { 1458 | while (count--) { 1459 | if (!responses.length) throw new Error('No more pending request to flush !'); 1460 | responses.shift()(); 1461 | } 1462 | } else { 1463 | while (responses.length) { 1464 | responses.shift()(); 1465 | } 1466 | } 1467 | $httpBackend.verifyNoOutstandingExpectation(); 1468 | }; 1469 | 1470 | 1471 | /** 1472 | * @ngdoc method 1473 | * @name $httpBackend#verifyNoOutstandingExpectation 1474 | * @description 1475 | * Verifies that all of the requests defined via the `expect` api were made. If any of the 1476 | * requests were not made, verifyNoOutstandingExpectation throws an exception. 1477 | * 1478 | * Typically, you would call this method following each test case that asserts requests using an 1479 | * "afterEach" clause. 1480 | * 1481 | * ```js 1482 | * afterEach($httpBackend.verifyNoOutstandingExpectation); 1483 | * ``` 1484 | */ 1485 | $httpBackend.verifyNoOutstandingExpectation = function() { 1486 | $rootScope.$digest(); 1487 | if (expectations.length) { 1488 | throw new Error('Unsatisfied requests: ' + expectations.join(', ')); 1489 | } 1490 | }; 1491 | 1492 | 1493 | /** 1494 | * @ngdoc method 1495 | * @name $httpBackend#verifyNoOutstandingRequest 1496 | * @description 1497 | * Verifies that there are no outstanding requests that need to be flushed. 1498 | * 1499 | * Typically, you would call this method following each test case that asserts requests using an 1500 | * "afterEach" clause. 1501 | * 1502 | * ```js 1503 | * afterEach($httpBackend.verifyNoOutstandingRequest); 1504 | * ``` 1505 | */ 1506 | $httpBackend.verifyNoOutstandingRequest = function() { 1507 | if (responses.length) { 1508 | throw new Error('Unflushed requests: ' + responses.length); 1509 | } 1510 | }; 1511 | 1512 | 1513 | /** 1514 | * @ngdoc method 1515 | * @name $httpBackend#resetExpectations 1516 | * @description 1517 | * Resets all request expectations, but preserves all backend definitions. Typically, you would 1518 | * call resetExpectations during a multiple-phase test when you want to reuse the same instance of 1519 | * $httpBackend mock. 1520 | */ 1521 | $httpBackend.resetExpectations = function() { 1522 | expectations.length = 0; 1523 | responses.length = 0; 1524 | }; 1525 | 1526 | return $httpBackend; 1527 | 1528 | 1529 | function createShortMethods(prefix) { 1530 | angular.forEach(['GET', 'DELETE', 'JSONP', 'HEAD'], function(method) { 1531 | $httpBackend[prefix + method] = function(url, headers) { 1532 | return $httpBackend[prefix](method, url, undefined, headers); 1533 | }; 1534 | }); 1535 | 1536 | angular.forEach(['PUT', 'POST', 'PATCH'], function(method) { 1537 | $httpBackend[prefix + method] = function(url, data, headers) { 1538 | return $httpBackend[prefix](method, url, data, headers); 1539 | }; 1540 | }); 1541 | } 1542 | } 1543 | 1544 | function MockHttpExpectation(method, url, data, headers) { 1545 | 1546 | this.data = data; 1547 | this.headers = headers; 1548 | 1549 | this.match = function(m, u, d, h) { 1550 | if (method != m) return false; 1551 | if (!this.matchUrl(u)) return false; 1552 | if (angular.isDefined(d) && !this.matchData(d)) return false; 1553 | if (angular.isDefined(h) && !this.matchHeaders(h)) return false; 1554 | return true; 1555 | }; 1556 | 1557 | this.matchUrl = function(u) { 1558 | if (!url) return true; 1559 | if (angular.isFunction(url.test)) return url.test(u); 1560 | return url == u; 1561 | }; 1562 | 1563 | this.matchHeaders = function(h) { 1564 | if (angular.isUndefined(headers)) return true; 1565 | if (angular.isFunction(headers)) return headers(h); 1566 | return angular.equals(headers, h); 1567 | }; 1568 | 1569 | this.matchData = function(d) { 1570 | if (angular.isUndefined(data)) return true; 1571 | if (data && angular.isFunction(data.test)) return data.test(d); 1572 | if (data && angular.isFunction(data)) return data(d); 1573 | if (data && !angular.isString(data)) { 1574 | return angular.equals(angular.fromJson(angular.toJson(data)), angular.fromJson(d)); 1575 | } 1576 | return data == d; 1577 | }; 1578 | 1579 | this.toString = function() { 1580 | return method + ' ' + url; 1581 | }; 1582 | } 1583 | 1584 | function createMockXhr() { 1585 | return new MockXhr(); 1586 | } 1587 | 1588 | function MockXhr() { 1589 | 1590 | // hack for testing $http, $httpBackend 1591 | MockXhr.$$lastInstance = this; 1592 | 1593 | this.open = function(method, url, async) { 1594 | this.$$method = method; 1595 | this.$$url = url; 1596 | this.$$async = async; 1597 | this.$$reqHeaders = {}; 1598 | this.$$respHeaders = {}; 1599 | }; 1600 | 1601 | this.send = function(data) { 1602 | this.$$data = data; 1603 | }; 1604 | 1605 | this.setRequestHeader = function(key, value) { 1606 | this.$$reqHeaders[key] = value; 1607 | }; 1608 | 1609 | this.getResponseHeader = function(name) { 1610 | // the lookup must be case insensitive, 1611 | // that's why we try two quick lookups first and full scan last 1612 | var header = this.$$respHeaders[name]; 1613 | if (header) return header; 1614 | 1615 | name = angular.lowercase(name); 1616 | header = this.$$respHeaders[name]; 1617 | if (header) return header; 1618 | 1619 | header = undefined; 1620 | angular.forEach(this.$$respHeaders, function(headerVal, headerName) { 1621 | if (!header && angular.lowercase(headerName) == name) header = headerVal; 1622 | }); 1623 | return header; 1624 | }; 1625 | 1626 | this.getAllResponseHeaders = function() { 1627 | var lines = []; 1628 | 1629 | angular.forEach(this.$$respHeaders, function(value, key) { 1630 | lines.push(key + ': ' + value); 1631 | }); 1632 | return lines.join('\n'); 1633 | }; 1634 | 1635 | this.abort = angular.noop; 1636 | } 1637 | 1638 | 1639 | /** 1640 | * @ngdoc service 1641 | * @name $timeout 1642 | * @description 1643 | * 1644 | * This service is just a simple decorator for {@link ng.$timeout $timeout} service 1645 | * that adds a "flush" and "verifyNoPendingTasks" methods. 1646 | */ 1647 | 1648 | angular.mock.$TimeoutDecorator = function($delegate, $browser) { 1649 | 1650 | /** 1651 | * @ngdoc method 1652 | * @name $timeout#flush 1653 | * @description 1654 | * 1655 | * Flushes the queue of pending tasks. 1656 | * 1657 | * @param {number=} delay maximum timeout amount to flush up until 1658 | */ 1659 | $delegate.flush = function(delay) { 1660 | $browser.defer.flush(delay); 1661 | }; 1662 | 1663 | /** 1664 | * @ngdoc method 1665 | * @name $timeout#verifyNoPendingTasks 1666 | * @description 1667 | * 1668 | * Verifies that there are no pending tasks that need to be flushed. 1669 | */ 1670 | $delegate.verifyNoPendingTasks = function() { 1671 | if ($browser.deferredFns.length) { 1672 | throw new Error('Deferred tasks to flush (' + $browser.deferredFns.length + '): ' + 1673 | formatPendingTasksAsString($browser.deferredFns)); 1674 | } 1675 | }; 1676 | 1677 | function formatPendingTasksAsString(tasks) { 1678 | var result = []; 1679 | angular.forEach(tasks, function(task) { 1680 | result.push('{id: ' + task.id + ', ' + 'time: ' + task.time + '}'); 1681 | }); 1682 | 1683 | return result.join(', '); 1684 | } 1685 | 1686 | return $delegate; 1687 | }; 1688 | 1689 | angular.mock.$RAFDecorator = function($delegate) { 1690 | var queue = []; 1691 | var rafFn = function(fn) { 1692 | var index = queue.length; 1693 | queue.push(fn); 1694 | return function() { 1695 | queue.splice(index, 1); 1696 | }; 1697 | }; 1698 | 1699 | rafFn.supported = $delegate.supported; 1700 | 1701 | rafFn.flush = function() { 1702 | if(queue.length === 0) { 1703 | throw new Error('No rAF callbacks present'); 1704 | } 1705 | 1706 | var length = queue.length; 1707 | for(var i=0;i'); 1737 | }; 1738 | }; 1739 | 1740 | /** 1741 | * @ngdoc module 1742 | * @name ngMock 1743 | * @packageName angular-mocks 1744 | * @description 1745 | * 1746 | * # ngMock 1747 | * 1748 | * The `ngMock` module provides support to inject and mock Angular services into unit tests. 1749 | * In addition, ngMock also extends various core ng services such that they can be 1750 | * inspected and controlled in a synchronous manner within test code. 1751 | * 1752 | * 1753 | *
1754 | * 1755 | */ 1756 | angular.module('ngMock', ['ng']).provider({ 1757 | $browser: angular.mock.$BrowserProvider, 1758 | $exceptionHandler: angular.mock.$ExceptionHandlerProvider, 1759 | $log: angular.mock.$LogProvider, 1760 | $interval: angular.mock.$IntervalProvider, 1761 | $httpBackend: angular.mock.$HttpBackendProvider, 1762 | $rootElement: angular.mock.$RootElementProvider 1763 | }).config(['$provide', function($provide) { 1764 | $provide.decorator('$timeout', angular.mock.$TimeoutDecorator); 1765 | $provide.decorator('$$rAF', angular.mock.$RAFDecorator); 1766 | $provide.decorator('$$asyncCallback', angular.mock.$AsyncCallbackDecorator); 1767 | }]); 1768 | 1769 | /** 1770 | * @ngdoc module 1771 | * @name ngMockE2E 1772 | * @module ngMockE2E 1773 | * @packageName angular-mocks 1774 | * @description 1775 | * 1776 | * The `ngMockE2E` is an angular module which contains mocks suitable for end-to-end testing. 1777 | * Currently there is only one mock present in this module - 1778 | * the {@link ngMockE2E.$httpBackend e2e $httpBackend} mock. 1779 | */ 1780 | angular.module('ngMockE2E', ['ng']).config(['$provide', function($provide) { 1781 | $provide.decorator('$httpBackend', angular.mock.e2e.$httpBackendDecorator); 1782 | }]); 1783 | 1784 | /** 1785 | * @ngdoc service 1786 | * @name $httpBackend 1787 | * @module ngMockE2E 1788 | * @description 1789 | * Fake HTTP backend implementation suitable for end-to-end testing or backend-less development of 1790 | * applications that use the {@link ng.$http $http service}. 1791 | * 1792 | * *Note*: For fake http backend implementation suitable for unit testing please see 1793 | * {@link ngMock.$httpBackend unit-testing $httpBackend mock}. 1794 | * 1795 | * This implementation can be used to respond with static or dynamic responses via the `when` api 1796 | * and its shortcuts (`whenGET`, `whenPOST`, etc) and optionally pass through requests to the 1797 | * real $httpBackend for specific requests (e.g. to interact with certain remote apis or to fetch 1798 | * templates from a webserver). 1799 | * 1800 | * As opposed to unit-testing, in an end-to-end testing scenario or in scenario when an application 1801 | * is being developed with the real backend api replaced with a mock, it is often desirable for 1802 | * certain category of requests to bypass the mock and issue a real http request (e.g. to fetch 1803 | * templates or static files from the webserver). To configure the backend with this behavior 1804 | * use the `passThrough` request handler of `when` instead of `respond`. 1805 | * 1806 | * Additionally, we don't want to manually have to flush mocked out requests like we do during unit 1807 | * testing. For this reason the e2e $httpBackend flushes mocked out requests 1808 | * automatically, closely simulating the behavior of the XMLHttpRequest object. 1809 | * 1810 | * To setup the application to run with this http backend, you have to create a module that depends 1811 | * on the `ngMockE2E` and your application modules and defines the fake backend: 1812 | * 1813 | * ```js 1814 | * myAppDev = angular.module('myAppDev', ['myApp', 'ngMockE2E']); 1815 | * myAppDev.run(function($httpBackend) { 1816 | * phones = [{name: 'phone1'}, {name: 'phone2'}]; 1817 | * 1818 | * // returns the current list of phones 1819 | * $httpBackend.whenGET('/phones').respond(phones); 1820 | * 1821 | * // adds a new phone to the phones array 1822 | * $httpBackend.whenPOST('/phones').respond(function(method, url, data) { 1823 | * var phone = angular.fromJson(data); 1824 | * phones.push(phone); 1825 | * return [200, phone, {}]; 1826 | * }); 1827 | * $httpBackend.whenGET(/^\/templates\//).passThrough(); 1828 | * //... 1829 | * }); 1830 | * ``` 1831 | * 1832 | * Afterwards, bootstrap your app with this new module. 1833 | */ 1834 | 1835 | /** 1836 | * @ngdoc method 1837 | * @name $httpBackend#when 1838 | * @module ngMockE2E 1839 | * @description 1840 | * Creates a new backend definition. 1841 | * 1842 | * @param {string} method HTTP method. 1843 | * @param {string|RegExp} url HTTP url. 1844 | * @param {(string|RegExp)=} data HTTP request body. 1845 | * @param {(Object|function(Object))=} headers HTTP headers or function that receives http header 1846 | * object and returns true if the headers match the current definition. 1847 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1848 | * control how a matched request is handled. 1849 | * 1850 | * - respond – 1851 | * `{function([status,] data[, headers, statusText]) 1852 | * | function(function(method, url, data, headers)}` 1853 | * – The respond method takes a set of static data to be returned or a function that can return 1854 | * an array containing response status (number), response data (string), response headers 1855 | * (Object), and the text for the status (string). 1856 | * - passThrough – `{function()}` – Any request matching a backend definition with 1857 | * `passThrough` handler will be passed through to the real backend (an XHR request will be made 1858 | * to the server.) 1859 | */ 1860 | 1861 | /** 1862 | * @ngdoc method 1863 | * @name $httpBackend#whenGET 1864 | * @module ngMockE2E 1865 | * @description 1866 | * Creates a new backend definition for GET requests. For more info see `when()`. 1867 | * 1868 | * @param {string|RegExp} url HTTP url. 1869 | * @param {(Object|function(Object))=} headers HTTP headers. 1870 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1871 | * control how a matched request is handled. 1872 | */ 1873 | 1874 | /** 1875 | * @ngdoc method 1876 | * @name $httpBackend#whenHEAD 1877 | * @module ngMockE2E 1878 | * @description 1879 | * Creates a new backend definition for HEAD requests. For more info see `when()`. 1880 | * 1881 | * @param {string|RegExp} url HTTP url. 1882 | * @param {(Object|function(Object))=} headers HTTP headers. 1883 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1884 | * control how a matched request is handled. 1885 | */ 1886 | 1887 | /** 1888 | * @ngdoc method 1889 | * @name $httpBackend#whenDELETE 1890 | * @module ngMockE2E 1891 | * @description 1892 | * Creates a new backend definition for DELETE requests. For more info see `when()`. 1893 | * 1894 | * @param {string|RegExp} url HTTP url. 1895 | * @param {(Object|function(Object))=} headers HTTP headers. 1896 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1897 | * control how a matched request is handled. 1898 | */ 1899 | 1900 | /** 1901 | * @ngdoc method 1902 | * @name $httpBackend#whenPOST 1903 | * @module ngMockE2E 1904 | * @description 1905 | * Creates a new backend definition for POST requests. For more info see `when()`. 1906 | * 1907 | * @param {string|RegExp} url HTTP url. 1908 | * @param {(string|RegExp)=} data HTTP request body. 1909 | * @param {(Object|function(Object))=} headers HTTP headers. 1910 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1911 | * control how a matched request is handled. 1912 | */ 1913 | 1914 | /** 1915 | * @ngdoc method 1916 | * @name $httpBackend#whenPUT 1917 | * @module ngMockE2E 1918 | * @description 1919 | * Creates a new backend definition for PUT requests. For more info see `when()`. 1920 | * 1921 | * @param {string|RegExp} url HTTP url. 1922 | * @param {(string|RegExp)=} data HTTP request body. 1923 | * @param {(Object|function(Object))=} headers HTTP headers. 1924 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1925 | * control how a matched request is handled. 1926 | */ 1927 | 1928 | /** 1929 | * @ngdoc method 1930 | * @name $httpBackend#whenPATCH 1931 | * @module ngMockE2E 1932 | * @description 1933 | * Creates a new backend definition for PATCH requests. For more info see `when()`. 1934 | * 1935 | * @param {string|RegExp} url HTTP url. 1936 | * @param {(string|RegExp)=} data HTTP request body. 1937 | * @param {(Object|function(Object))=} headers HTTP headers. 1938 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1939 | * control how a matched request is handled. 1940 | */ 1941 | 1942 | /** 1943 | * @ngdoc method 1944 | * @name $httpBackend#whenJSONP 1945 | * @module ngMockE2E 1946 | * @description 1947 | * Creates a new backend definition for JSONP requests. For more info see `when()`. 1948 | * 1949 | * @param {string|RegExp} url HTTP url. 1950 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1951 | * control how a matched request is handled. 1952 | */ 1953 | angular.mock.e2e = {}; 1954 | angular.mock.e2e.$httpBackendDecorator = 1955 | ['$rootScope', '$delegate', '$browser', createHttpBackendMock]; 1956 | 1957 | 1958 | angular.mock.clearDataCache = function() { 1959 | var key, 1960 | cache = angular.element.cache; 1961 | 1962 | for(key in cache) { 1963 | if (Object.prototype.hasOwnProperty.call(cache,key)) { 1964 | var handle = cache[key].handle; 1965 | 1966 | handle && angular.element(handle.elem).off(); 1967 | delete cache[key]; 1968 | } 1969 | } 1970 | }; 1971 | 1972 | 1973 | if(window.jasmine || window.mocha) { 1974 | 1975 | var currentSpec = null, 1976 | isSpecRunning = function() { 1977 | return !!currentSpec; 1978 | }; 1979 | 1980 | 1981 | (window.beforeEach || window.setup)(function() { 1982 | currentSpec = this; 1983 | }); 1984 | 1985 | (window.afterEach || window.teardown)(function() { 1986 | var injector = currentSpec.$injector; 1987 | 1988 | angular.forEach(currentSpec.$modules, function(module) { 1989 | if (module && module.$$hashKey) { 1990 | module.$$hashKey = undefined; 1991 | } 1992 | }); 1993 | 1994 | currentSpec.$injector = null; 1995 | currentSpec.$modules = null; 1996 | currentSpec = null; 1997 | 1998 | if (injector) { 1999 | injector.get('$rootElement').off(); 2000 | injector.get('$browser').pollFns.length = 0; 2001 | } 2002 | 2003 | angular.mock.clearDataCache(); 2004 | 2005 | // clean up jquery's fragment cache 2006 | angular.forEach(angular.element.fragments, function(val, key) { 2007 | delete angular.element.fragments[key]; 2008 | }); 2009 | 2010 | MockXhr.$$lastInstance = null; 2011 | 2012 | angular.forEach(angular.callbacks, function(val, key) { 2013 | delete angular.callbacks[key]; 2014 | }); 2015 | angular.callbacks.counter = 0; 2016 | }); 2017 | 2018 | /** 2019 | * @ngdoc function 2020 | * @name angular.mock.module 2021 | * @description 2022 | * 2023 | * *NOTE*: This function is also published on window for easy access.
2024 | * *NOTE*: This function is declared ONLY WHEN running tests with jasmine or mocha 2025 | * 2026 | * This function registers a module configuration code. It collects the configuration information 2027 | * which will be used when the injector is created by {@link angular.mock.inject inject}. 2028 | * 2029 | * See {@link angular.mock.inject inject} for usage example 2030 | * 2031 | * @param {...(string|Function|Object)} fns any number of modules which are represented as string 2032 | * aliases or as anonymous module initialization functions. The modules are used to 2033 | * configure the injector. The 'ng' and 'ngMock' modules are automatically loaded. If an 2034 | * object literal is passed they will be registered as values in the module, the key being 2035 | * the module name and the value being what is returned. 2036 | */ 2037 | window.module = angular.mock.module = function() { 2038 | var moduleFns = Array.prototype.slice.call(arguments, 0); 2039 | return isSpecRunning() ? workFn() : workFn; 2040 | ///////////////////// 2041 | function workFn() { 2042 | if (currentSpec.$injector) { 2043 | throw new Error('Injector already created, can not register a module!'); 2044 | } else { 2045 | var modules = currentSpec.$modules || (currentSpec.$modules = []); 2046 | angular.forEach(moduleFns, function(module) { 2047 | if (angular.isObject(module) && !angular.isArray(module)) { 2048 | modules.push(function($provide) { 2049 | angular.forEach(module, function(value, key) { 2050 | $provide.value(key, value); 2051 | }); 2052 | }); 2053 | } else { 2054 | modules.push(module); 2055 | } 2056 | }); 2057 | } 2058 | } 2059 | }; 2060 | 2061 | /** 2062 | * @ngdoc function 2063 | * @name angular.mock.inject 2064 | * @description 2065 | * 2066 | * *NOTE*: This function is also published on window for easy access.
2067 | * *NOTE*: This function is declared ONLY WHEN running tests with jasmine or mocha 2068 | * 2069 | * The inject function wraps a function into an injectable function. The inject() creates new 2070 | * instance of {@link auto.$injector $injector} per test, which is then used for 2071 | * resolving references. 2072 | * 2073 | * 2074 | * ## Resolving References (Underscore Wrapping) 2075 | * Often, we would like to inject a reference once, in a `beforeEach()` block and reuse this 2076 | * in multiple `it()` clauses. To be able to do this we must assign the reference to a variable 2077 | * that is declared in the scope of the `describe()` block. Since we would, most likely, want 2078 | * the variable to have the same name of the reference we have a problem, since the parameter 2079 | * to the `inject()` function would hide the outer variable. 2080 | * 2081 | * To help with this, the injected parameters can, optionally, be enclosed with underscores. 2082 | * These are ignored by the injector when the reference name is resolved. 2083 | * 2084 | * For example, the parameter `_myService_` would be resolved as the reference `myService`. 2085 | * Since it is available in the function body as _myService_, we can then assign it to a variable 2086 | * defined in an outer scope. 2087 | * 2088 | * ``` 2089 | * // Defined out reference variable outside 2090 | * var myService; 2091 | * 2092 | * // Wrap the parameter in underscores 2093 | * beforeEach( inject( function(_myService_){ 2094 | * myService = _myService_; 2095 | * })); 2096 | * 2097 | * // Use myService in a series of tests. 2098 | * it('makes use of myService', function() { 2099 | * myService.doStuff(); 2100 | * }); 2101 | * 2102 | * ``` 2103 | * 2104 | * See also {@link angular.mock.module angular.mock.module} 2105 | * 2106 | * ## Example 2107 | * Example of what a typical jasmine tests looks like with the inject method. 2108 | * ```js 2109 | * 2110 | * angular.module('myApplicationModule', []) 2111 | * .value('mode', 'app') 2112 | * .value('version', 'v1.0.1'); 2113 | * 2114 | * 2115 | * describe('MyApp', function() { 2116 | * 2117 | * // You need to load modules that you want to test, 2118 | * // it loads only the "ng" module by default. 2119 | * beforeEach(module('myApplicationModule')); 2120 | * 2121 | * 2122 | * // inject() is used to inject arguments of all given functions 2123 | * it('should provide a version', inject(function(mode, version) { 2124 | * expect(version).toEqual('v1.0.1'); 2125 | * expect(mode).toEqual('app'); 2126 | * })); 2127 | * 2128 | * 2129 | * // The inject and module method can also be used inside of the it or beforeEach 2130 | * it('should override a version and test the new version is injected', function() { 2131 | * // module() takes functions or strings (module aliases) 2132 | * module(function($provide) { 2133 | * $provide.value('version', 'overridden'); // override version here 2134 | * }); 2135 | * 2136 | * inject(function(version) { 2137 | * expect(version).toEqual('overridden'); 2138 | * }); 2139 | * }); 2140 | * }); 2141 | * 2142 | * ``` 2143 | * 2144 | * @param {...Function} fns any number of functions which will be injected using the injector. 2145 | */ 2146 | 2147 | 2148 | 2149 | var ErrorAddingDeclarationLocationStack = function(e, errorForStack) { 2150 | this.message = e.message; 2151 | this.name = e.name; 2152 | if (e.line) this.line = e.line; 2153 | if (e.sourceId) this.sourceId = e.sourceId; 2154 | if (e.stack && errorForStack) 2155 | this.stack = e.stack + '\n' + errorForStack.stack; 2156 | if (e.stackArray) this.stackArray = e.stackArray; 2157 | }; 2158 | ErrorAddingDeclarationLocationStack.prototype.toString = Error.prototype.toString; 2159 | 2160 | window.inject = angular.mock.inject = function() { 2161 | var blockFns = Array.prototype.slice.call(arguments, 0); 2162 | var errorForStack = new Error('Declaration Location'); 2163 | return isSpecRunning() ? workFn.call(currentSpec) : workFn; 2164 | ///////////////////// 2165 | function workFn() { 2166 | var modules = currentSpec.$modules || []; 2167 | 2168 | modules.unshift('ngMock'); 2169 | modules.unshift('ng'); 2170 | var injector = currentSpec.$injector; 2171 | if (!injector) { 2172 | injector = currentSpec.$injector = angular.injector(modules); 2173 | } 2174 | for(var i = 0, ii = blockFns.length; i < ii; i++) { 2175 | try { 2176 | /* jshint -W040 *//* Jasmine explicitly provides a `this` object when calling functions */ 2177 | injector.invoke(blockFns[i] || angular.noop, this); 2178 | /* jshint +W040 */ 2179 | } catch (e) { 2180 | if (e.stack && errorForStack) { 2181 | throw new ErrorAddingDeclarationLocationStack(e, errorForStack); 2182 | } 2183 | throw e; 2184 | } finally { 2185 | errorForStack = null; 2186 | } 2187 | } 2188 | } 2189 | }; 2190 | } 2191 | 2192 | 2193 | })(window, window.angular); 2194 | --------------------------------------------------------------------------------