diff --git a/README.md b/README.md index e1bc9108b..5d0d274dc 100644 --- a/README.md +++ b/README.md @@ -14,20 +14,20 @@ The full tutorial can be found at http://docs.angularjs.org/tutorial. ### Git -- A good place to learn about setting up git is [here][git-github] -- Git [home][git-home] (download, documentation) +- A good place to learn about setting up git is [here][git-github]. +- Git [home][git-home] (download, documentation). ### Node.js and Tools - Get [Node.js][node-download]. -- Install the tool dependencies (`npm install`) +- Install the tool dependencies (`npm install`). ## Workings of the application - The application filesystem layout structure is based on the [angular-seed] project. - There is no dynamic backend (no application server) for this application. Instead we fake the - an application server by fetching static json files. + application server by fetching static json files. - Read the Development section at the end to familiarize yourself with running and developing an angular application. @@ -41,8 +41,8 @@ To see the changes which between any two lessons use the git diff command. ### step-0 -- Add ngApp directive to bootstrap the app -- Add simple template with an expression +- Add ngApp directive to bootstrap the app. +- Add simple template with an expression. ### step-1 @@ -54,12 +54,12 @@ To see the changes which between any two lessons use the git diff command. ### step-2 - Convert the static html list into dynamic one by: - - creating `PhoneListCtrl` controller for the application + - creating `PhoneListCtrl` controller for the application. - extracting the data from HTML, moving it into the controller as an in-memory dataset. - converting the static HTML document into an Angular template with the use of the `ngRepeat` directive which iterates over the dataset of phones. `ngRepeat` clones its contents for each instance in the dataset and renders it into the view. -- Add a simple unit test to show off how to write tests and run them with Karma +- Add a simple unit test to show off how to write tests and run them with Karma. ### step-3 diff --git a/app/css/animations.css b/app/css/animations.css new file mode 100644 index 000000000..46f3da6ec --- /dev/null +++ b/app/css/animations.css @@ -0,0 +1,97 @@ +/* + * animations css stylesheet + */ + +/* animate ngRepeat in phone listing */ + +.phone-listing.ng-enter, +.phone-listing.ng-leave, +.phone-listing.ng-move { + -webkit-transition: 0.5s linear all; + -moz-transition: 0.5s linear all; + -o-transition: 0.5s linear all; + transition: 0.5s linear all; +} + +.phone-listing.ng-enter, +.phone-listing.ng-move { + opacity: 0; + height: 0; + overflow: hidden; +} + +.phone-listing.ng-move.ng-move-active, +.phone-listing.ng-enter.ng-enter-active { + opacity: 1; + height: 120px; +} + +.phone-listing.ng-leave { + opacity: 1; + overflow: hidden; +} + +.phone-listing.ng-leave.ng-leave-active { + opacity: 0; + height: 0; + padding-top: 0; + padding-bottom: 0; +} + +/* cross fading between routes with ngView */ + +.view-container { + position: relative; +} + +.view-frame.ng-enter, +.view-frame.ng-leave { + background: white; + position: absolute; + top: 0; + left: 0; + right: 0; +} + +.view-frame.ng-enter { + -webkit-animation: 0.5s fade-in; + -moz-animation: 0.5s fade-in; + -o-animation: 0.5s fade-in; + animation: 0.5s fade-in; + z-index: 100; +} + +.view-frame.ng-leave { + -webkit-animation: 0.5s fade-out; + -moz-animation: 0.5s fade-out; + -o-animation: 0.5s fade-out; + animation: 0.5s fade-out; + z-index: 99; +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} +@-moz-keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} +@-webkit-keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes fade-out { + from { opacity: 1; } + to { opacity: 0; } +} +@-moz-keyframes fade-out { + from { opacity: 1; } + to { opacity: 0; } +} +@-webkit-keyframes fade-out { + from { opacity: 1; } + to { opacity: 0; } +} + diff --git a/app/css/app.css b/app/css/app.css index 8d3eae692..951ea087c 100644 --- a/app/css/app.css +++ b/app/css/app.css @@ -1 +1,99 @@ /* app css stylesheet */ + +body { + padding-top: 20px; +} + + +.phone-images { + background-color: white; + width: 450px; + height: 450px; + overflow: hidden; + position: relative; + float: left; +} + +.phones { + list-style: none; +} + +.thumb { + float: left; + margin: -0.5em 1em 1.5em 0; + padding-bottom: 1em; + height: 100px; + width: 100px; +} + +.phones li { + clear: both; + height: 115px; + padding-top: 15px; +} + +/** Detail View **/ +img.phone { + float: left; + margin-right: 3em; + margin-bottom: 2em; + background-color: white; + padding: 2em; + height: 400px; + width: 400px; + display: none; +} + +img.phone:first-child { + display: block; +} + + +ul.phone-thumbs { + margin: 0; + list-style: none; +} + +ul.phone-thumbs li { + border: 1px solid black; + display: inline-block; + margin: 1em; + background-color: white; +} + +ul.phone-thumbs img { + height: 100px; + width: 100px; + padding: 1em; +} + +ul.phone-thumbs img:hover { + cursor: pointer; +} + + +ul.specs { + clear: both; + margin: 0; + padding: 0; + list-style: none; +} + +ul.specs > li{ + display: inline-block; + width: 200px; + vertical-align: top; +} + +ul.specs > li > span{ + font-weight: bold; + font-size: 1.2em; +} + +ul.specs dt { + font-weight: bold; +} + +h1 { + border-bottom: 1px solid gray; +} diff --git a/app/index.html b/app/index.html index f4c7d5c4d..ad6c2d979 100644 --- a/app/index.html +++ b/app/index.html @@ -1,12 +1,28 @@ - + - My HTML File + Google Phone Gallery + + + + + + + + + + + + +
+
+
+ - \ No newline at end of file + diff --git a/app/js/animations.js b/app/js/animations.js new file mode 100644 index 000000000..8f3404265 --- /dev/null +++ b/app/js/animations.js @@ -0,0 +1,52 @@ +var phonecatAnimations = angular.module('phonecatAnimations', ['ngAnimate']); + +phonecatAnimations.animation('.phone', function() { + + var animateUp = function(element, className, done) { + if(className != 'active') { + return; + } + element.css({ + position: 'absolute', + top: 500, + left: 0, + display: 'block' + }); + + jQuery(element).animate({ + top: 0 + }, done); + + return function(cancel) { + if(cancel) { + element.stop(); + } + }; + } + + var animateDown = function(element, className, done) { + if(className != 'active') { + return; + } + element.css({ + position: 'absolute', + left: 0, + top: 0 + }); + + jQuery(element).animate({ + top: -500 + }, done); + + return function(cancel) { + if(cancel) { + element.stop(); + } + }; + } + + return { + addClass: animateUp, + removeClass: animateDown + }; +}); diff --git a/app/js/app.js b/app/js/app.js index 7a8f274a0..a58955cd1 100644 --- a/app/js/app.js +++ b/app/js/app.js @@ -1,3 +1,28 @@ 'use strict'; /* App Module */ + +var phonecatApp = angular.module('phonecatApp', [ + 'ngRoute', + 'phonecatAnimations', + + 'phonecatControllers', + 'phonecatFilters', + 'phonecatServices' +]); + +phonecatApp.config(['$routeProvider', + function($routeProvider) { + $routeProvider. + when('/phones', { + templateUrl: 'partials/phone-list.html', + controller: 'PhoneListCtrl' + }). + when('/phones/:phoneId', { + templateUrl: 'partials/phone-detail.html', + controller: 'PhoneDetailCtrl' + }). + otherwise({ + redirectTo: '/phones' + }); + }]); diff --git a/app/js/controllers.js b/app/js/controllers.js index d314a3331..c8ecfbba1 100644 --- a/app/js/controllers.js +++ b/app/js/controllers.js @@ -1,3 +1,22 @@ 'use strict'; /* Controllers */ + +var phonecatControllers = angular.module('phonecatControllers', []); + +phonecatControllers.controller('PhoneListCtrl', ['$scope', 'Phone', + function($scope, Phone) { + $scope.phones = Phone.query(); + $scope.orderProp = 'age'; + }]); + +phonecatControllers.controller('PhoneDetailCtrl', ['$scope', '$routeParams', 'Phone', + function($scope, $routeParams, Phone) { + $scope.phone = Phone.get({phoneId: $routeParams.phoneId}, function(phone) { + $scope.mainImageUrl = phone.images[0]; + }); + + $scope.setImage = function(imageUrl) { + $scope.mainImageUrl = imageUrl; + } + }]); diff --git a/app/js/filters.js b/app/js/filters.js index 85e8440f8..4f62309ba 100644 --- a/app/js/filters.js +++ b/app/js/filters.js @@ -1,3 +1,9 @@ 'use strict'; /* Filters */ + +angular.module('phonecatFilters', []).filter('checkmark', function() { + return function(input) { + return input ? '\u2713' : '\u2718'; + }; +}); diff --git a/app/js/services.js b/app/js/services.js index 8207480df..e0b81a8ac 100644 --- a/app/js/services.js +++ b/app/js/services.js @@ -2,3 +2,11 @@ /* Services */ +var phonecatServices = angular.module('phonecatServices', ['ngResource']); + +phonecatServices.factory('Phone', ['$resource', + function($resource){ + return $resource('phones/:phoneId.json', {}, { + query: {method:'GET', params:{phoneId:'phones'}, isArray:true} + }); + }]); diff --git a/app/partials/phone-detail.html b/app/partials/phone-detail.html new file mode 100644 index 000000000..5fc4da2ae --- /dev/null +++ b/app/partials/phone-detail.html @@ -0,0 +1,118 @@ +
+ +
+ +

{{phone.name}}

+ +

{{phone.description}}

+ + + + diff --git a/app/partials/phone-list.html b/app/partials/phone-list.html new file mode 100644 index 000000000..e9ec19e39 --- /dev/null +++ b/app/partials/phone-list.html @@ -0,0 +1,28 @@ +
+
+
+ + + Search: + Sort by: + + +
+
+ + + + +
+
+
diff --git a/bower.json b/bower.json index 3a7b6949a..c14b47247 100644 --- a/bower.json +++ b/bower.json @@ -9,6 +9,9 @@ "angular": "1.3.x", "angular-mocks": "1.3.x", "jquery": "1.10.2", - "bootstrap": "~3.1.1" + "bootstrap": "~3.1.1", + "angular-route": "1.3.x", + "angular-resource": "1.3.x", + "angular-animate": "1.3.x" } } diff --git a/test/e2e/scenarios.js b/test/e2e/scenarios.js index ed4b2c3e7..3847c9eba 100644 --- a/test/e2e/scenarios.js +++ b/test/e2e/scenarios.js @@ -2,10 +2,100 @@ /* http://docs.angularjs.org/guide/dev_guide.e2e-testing */ -describe('my app', function() { +describe('PhoneCat App', function() { - beforeEach(function() { + it('should redirect index.html to index.html#/phones', function() { browser.get('app/index.html'); + browser.getLocationAbsUrl().then(function(url) { + expect(url.split('#')[1]).toBe('/phones'); + }); }); + + describe('Phone list view', function() { + + beforeEach(function() { + browser.get('app/index.html#/phones'); + }); + + + it('should filter the phone list as a user types into the search box', function() { + + var phoneList = element.all(by.repeater('phone in phones')); + var query = element(by.model('query')); + + expect(phoneList.count()).toBe(20); + + query.sendKeys('nexus'); + expect(phoneList.count()).toBe(1); + + query.clear(); + query.sendKeys('motorola'); + expect(phoneList.count()).toBe(8); + }); + + + it('should be possible to control phone order via the drop down select box', function() { + + var phoneNameColumn = element.all(by.repeater('phone in phones').column('phone.name')); + var query = element(by.model('query')); + + function getNames() { + return phoneNameColumn.map(function(elm) { + return elm.getText(); + }); + } + + query.sendKeys('tablet'); //let's narrow the dataset to make the test assertions shorter + + expect(getNames()).toEqual([ + "Motorola XOOM\u2122 with Wi-Fi", + "MOTOROLA XOOM\u2122" + ]); + + element(by.model('orderProp')).element(by.css('option[value="name"]')).click(); + + expect(getNames()).toEqual([ + "MOTOROLA XOOM\u2122", + "Motorola XOOM\u2122 with Wi-Fi" + ]); + }); + + + it('should render phone specific links', function() { + var query = element(by.model('query')); + query.sendKeys('nexus'); + element.all(by.css('.phones li a')).first().click(); + browser.getLocationAbsUrl().then(function(url) { + expect(url.split('#')[1]).toBe('/phones/nexus-s'); + }); + }); + }); + + + describe('Phone detail view', function() { + + beforeEach(function() { + browser.get('app/index.html#/phones/nexus-s'); + }); + + + it('should display nexus-s page', function() { + expect(element(by.binding('phone.name')).getText()).toBe('Nexus S'); + }); + + + it('should display the first phone image as the main phone image', function() { + expect(element(by.css('img.phone.active')).getAttribute('src')).toMatch(/img\/phones\/nexus-s.0.jpg/); + }); + + + it('should swap main image if a thumbnail image is clicked on', function() { + element(by.css('.phone-thumbs li:nth-child(3) img')).click(); + expect(element(by.css('img.phone.active')).getAttribute('src')).toMatch(/img\/phones\/nexus-s.2.jpg/); + + element(by.css('.phone-thumbs li:nth-child(1) img')).click(); + expect(element(by.css('img.phone.active')).getAttribute('src')).toMatch(/img\/phones\/nexus-s.0.jpg/); + }); + }); }); diff --git a/test/karma.conf.js b/test/karma.conf.js index 60e360b29..9622a52b9 100644 --- a/test/karma.conf.js +++ b/test/karma.conf.js @@ -6,6 +6,8 @@ module.exports = function(config){ files : [ 'app/bower_components/angular/angular.js', 'app/bower_components/angular-route/angular-route.js', + 'app/bower_components/angular-resource/angular-resource.js', + 'app/bower_components/angular-animate/angular-animate.js', 'app/bower_components/angular-mocks/angular-mocks.js', 'app/js/**/*.js', 'test/unit/**/*.js' diff --git a/test/unit/controllersSpec.js b/test/unit/controllersSpec.js index 63d80c3c3..e85cc111a 100644 --- a/test/unit/controllersSpec.js +++ b/test/unit/controllersSpec.js @@ -1,11 +1,72 @@ 'use strict'; /* jasmine specs for controllers go here */ +describe('PhoneCat controllers', function() { -describe('controllers', function() { + beforeEach(function(){ + this.addMatchers({ + toEqualData: function(expected) { + return angular.equals(this.actual, expected); + } + }); + }); + + beforeEach(module('phonecatApp')); + beforeEach(module('phonecatServices')); + + describe('PhoneListCtrl', function(){ + var scope, ctrl, $httpBackend; + + beforeEach(inject(function(_$httpBackend_, $rootScope, $controller) { + $httpBackend = _$httpBackend_; + $httpBackend.expectGET('phones/phones.json'). + respond([{name: 'Nexus S'}, {name: 'Motorola DROID'}]); + + scope = $rootScope.$new(); + ctrl = $controller('PhoneListCtrl', {$scope: scope}); + })); + + + it('should create "phones" model with 2 phones fetched from xhr', function() { + expect(scope.phones).toEqualData([]); + $httpBackend.flush(); + + expect(scope.phones).toEqualData( + [{name: 'Nexus S'}, {name: 'Motorola DROID'}]); + }); - it("should do something", function() { + it('should set the default value of orderProp model', function() { + expect(scope.orderProp).toBe('age'); + }); }); + + describe('PhoneDetailCtrl', function(){ + var scope, $httpBackend, ctrl, + xyzPhoneData = function() { + return { + name: 'phone xyz', + images: ['image/url1.png', 'image/url2.png'] + } + }; + + + beforeEach(inject(function(_$httpBackend_, $rootScope, $routeParams, $controller) { + $httpBackend = _$httpBackend_; + $httpBackend.expectGET('phones/xyz.json').respond(xyzPhoneData()); + + $routeParams.phoneId = 'xyz'; + scope = $rootScope.$new(); + ctrl = $controller('PhoneDetailCtrl', {$scope: scope}); + })); + + + it('should fetch phone detail', function() { + expect(scope.phone).toEqualData({}); + $httpBackend.flush(); + + expect(scope.phone).toEqualData(xyzPhoneData()); + }); + }); }); diff --git a/test/unit/filtersSpec.js b/test/unit/filtersSpec.js index 5fdc76a26..e5cbb7262 100644 --- a/test/unit/filtersSpec.js +++ b/test/unit/filtersSpec.js @@ -4,4 +4,15 @@ describe('filter', function() { + beforeEach(module('phonecatFilters')); + + + describe('checkmark', function() { + + it('should convert boolean values to unicode checkmark or cross', + inject(function(checkmarkFilter) { + expect(checkmarkFilter(true)).toBe('\u2713'); + expect(checkmarkFilter(false)).toBe('\u2718'); + })); + }); }); diff --git a/test/unit/servicesSpec.js b/test/unit/servicesSpec.js index db8a232de..b495870cb 100644 --- a/test/unit/servicesSpec.js +++ b/test/unit/servicesSpec.js @@ -1,7 +1,12 @@ 'use strict'; -/* jasmine specs for services go here */ - describe('service', function() { -}); + // load modules + beforeEach(module('phonecatApp')); + + // Test service availability + it('check the existence of Phone factory', inject(function(Phone) { + expect(Phone).toBeDefined(); + })); +}); \ No newline at end of file