Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Angular.js Workshop (PyCon SG 2014)

Angular.js Workshop (PyCon SG 2014)

David Cramer

June 18, 2014
Tweet

More Decks by David Cramer

Other Decks in Programming

Transcript

  1. <div ng-repeat="post in postList"> <div class="page-header"> <h2> <a href="/#/posts/{{ post.id

    }}">{{ post.title }}</a> </h2> </div> <div class="post-body">{{ post.body }}</div> </div>
  2. ๏ A LOT of code (mostly JS + Angular) ๏

    A fairly ugly ”modern” blog ๏ API-driven backend, client-driven frontend ๏ Basic dependency management organization ๏ Testing principles (in Flask and Angular)
  3. $ less README.rst Setup the environment: virtualenv ./env source ./env/bin/activate

    Install dependencies: # python pip install -e . # node (tools) npm install # bower (js 3rd party) node_modules/.bin/bower install
  4. $ less setup.py # ... tests_require = [ 'pytest>=2.5.0,<2.6.0', ]

    install_requires = [ 'blinker>=1.3,<1.4', 'flask>=0.10.1,<0.11.0', 'flask-restful>=0.2.12,<0.3.0', 'flask-sqlalchemy>=1.0,<1.1', 'sqlalchemy==0.9.4', ] # ...
  5. $ less package.json { "name": "pyconsg-tutorial-bootstrap", "private": true, "version": "0.0.0",

    "devDependencies": { "bower": "^1.3.1", "karma": "~0.10" }, "scripts": { "postinstall": "bower install", "pretest": "npm install", "test": "karma start tests/karma.conf.js" } }
  6. $ less bower.json { "name": "pyconsg-tutorial-bootstrap", "version": "0.0.0", "ignore": [

    "**/.*", "node_modules", "static/vendor", "tests" ], "dependencies": { "angular": "~1.2.17", "bootstrap": "~3.1.1" }, "resolutions": { "angular": "1.2.17" } }
  7. $ less blog/config.py # ... def create_app(**config): app = Flask(

    __name__, static_folder=os.path.join(PROJECT_ROOT, 'static'), template_folder=os.path.join(PROJECT_ROOT, 'templates'), ) app.config['INDUCE_API_DELAY'] = True app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/blog.db' app.config['DEBUG'] = True app.config.update(config) db.init_app(app) configure_api_routes(app) configure_web_routes(app) return app
  8. $ less blog/config.py # ... def configure_web_routes(app): from .web.index import

    IndexView app.add_url_rule( '/', view_func=IndexView.as_view('index'))
  9. $ less blog/config.py # ... def configure_api_routes(app): from .api.post_details import

    PostDetailsResource from .api.post_index import PostIndexResource api.add_resource(PostIndexResource, '/posts/') api.add_resource(PostDetailsResource, '/posts/<post_id>/') api.init_app(app)
  10. $ less blog/models/post.py from datetime import datetime from blog.config import

    db class Post(db.Model): id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(80)) body = db.Column(db.Text) pub_date = db.Column(db.DateTime)
  11. $ less blog/api/post_index.py from blog.api.base import Resource class PostIndexResource(Resource): def

    get(self): """ Return a list of posts. """ def post(self): """ Create a new post. """
  12. $ less blog/api/post_details.py from blog.api.base import Resource class PostDetailsResource(Resource): def

    get(self, post_id): """ Return information about a given post. """ def post(self, post_id): """ Edit an existing post. """
  13. $ less templates/index.html <!doctype html> <html> <head> <base href="{{ url_for('static',

    filename='partials/') }}"> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Blog</title> <link href="{{ url_for('static', filename='css/styles.css') }}” rel="stylesheet" media="screen"> <script src="{{ url_for('static', filename='vendor/angular/angular.js') }}"/> <script src="{{ url_for('static', filename='js/app.js') }}"/> </head> <body ng-app="blog"> <section class="container"> <header> <h1><a href="/#/">My Blog</a></h1> </header> <section ng-view></section> <footer>&copy; My Blog</footer> </section> </body> </html>
  14. ============================= test session starts ============================== platform darwin -- Python 2.7.6

    -- py-1.4.20 -- pytest-2.5.2 collected 1 items tests/python/api/test_post_index.py F =================================== FAILURES =================================== __________________________________ test_list ___________________________________ tests/python/api/test_post_index.py:31: in test_list > assert len(data) == 2 E TypeError: object of type 'NoneType' has no len() =========================== 1 failed in 0.11 seconds ==========================
  15. module.exports = function(config){ config.set({ basePath: '../', files: [ 'static/vendor/angular/angular.js', //

    'static/vendor/angular-route/angular-route.js', // 'static/vendor/angular-mocks/angular-mocks.js', 'static/js/**/*.js', 'tests/js/**/*.js' ], autoWatch: true, frameworks: ['jasmine'], browsers: ['Chrome'], plugins: [ 'karma-chrome-launcher', 'karma-jasmine' ] }); };
  16. INFO [karma]: Karma v0.10.10 server started at http://localhost:9876/ INFO [launcher]:

    Starting browser Chrome INFO [Chrome 35.0.1916 (Mac OS X 10.10.0)]: Connected on socket 0GvlxTukdDRVPKHoJxdB Chrome 35.0.1916 (Mac OS X 10.10.0) controllers encountered a declaration exception FAILED ReferenceError: module is not defined at null.<anonymous> (/Users/dcramer/Development/pyconsg-tutorial-bootstrap/ tests/js/controllersSpec.js:4:14) at /Users/dcramer/Development/pyconsg-tutorial-bootstrap/tests/js/ controllersSpec.js:3:1 Chrome 35.0.1916 (Mac OS X 10.10.0): Executed 1 of 1 (1 FAILED) (0 secs / 0.012 Chrome 35.0.1916 (Mac OS X 10.10.0): Executed 1 of 1 (1 FAILED) ERROR (0.153 secs / 0.012 secs)
  17. Error: [$injector:unpr] http://errors.angularjs.org/1.2.18/$injector/unpr? p0=NaNoobarProvider%20%3C-%20%24foobar at Error (native) at http://localhost:5000/static/vendor/angular/angular.min.js:6:450 at

    http://localhost:5000/static/vendor/angular/angular.min.js:36:145 at Object.c [as get] (http://localhost:5000/static/vendor/angular/ angular.min.js:34:236) at http://localhost:5000/static/vendor/angular/angular.min.js:36:213 at c (http://localhost:5000/static/vendor/angular/angular.min.js:34:236) at d (http://localhost:5000/static/vendor/angular/angular.min.js:34:453) at Object.instantiate (http://localhost:5000/static/vendor/angular/ angular.min.js:35:103) at http://localhost:5000/static/vendor/angular/angular.min.js:67:284 at link (http://localhost:5000/static/vendor/angular-route/angular- route.min.js:7:248)
  18. # create the database $ bin/create-db # load sample data

    $ bin/load-mocks # run the webserver $ bin/web
  19. $ less blog/api/post_index.py from blog.api.base import Resource from blog.models import

    Post class PostIndexResource(Resource): def get(self): """ Return a list of posts. """ post_list = Post.query.order_by( Post.pub_date.desc() )[:10] results = [] for post in post_list: results.append({ 'id': post.id, 'title': post.title, 'body': post.body, 'pubDate': post.pub_date.isoformat(), }) return results
  20. $ less tests/python/api/test_post_index.py import json from datetime import datetime from

    blog.config import db from blog.models import Post def test_list(client): post1 = Post( title='Hello world!', body='Lorem ipsum dolor sit amet, consectetur adipiscing elit.', pub_date=datetime(2013, 9, 19, 22, 15, 24), ) db.session.add(post1) post2 = Post( title='Hello world (again)!', body='Integer ullamcorper erat ac aliquam mollis.', pub_date=datetime(2013, 9, 20, 22, 15, 24), ) db.session.add(post2) db.session.commit() resp = client.get('/api/0/posts/') assert resp.status_code == 200 data = json.loads(resp.data.decode('utf-8')) assert len(data) == 2 assert data[0]['id'] == post2.id assert data[1]['id'] == post1.id
  21. // The preferred longer-version of dependency declaration. controller(['$http', function($http) {}]);

    // The shorter version we're going to be using, which // does not work with Internet Explorer. controller(function($http) {});
  22. $ vi static/js/app.js 'use strict'; angular.module('blog', [ // add ngRoute

    dependency 'ngRoute', // add our controller dependency 'blog.controllers' ]).config(function($routeProvider) { $routeProvider .when('/', { templateUrl: 'post-list.html', controller: 'PostListCtrl' }); });
  23. $ less bower.json { // ... "dependencies": { "angular": "~1.2.17",

    "bootstrap": "~3.1.1", "angular-route": "1.2.17" } }
  24. $ less templates/index.html <!-- ... --> <script src="{{ url_for('static', filename='vendor/angular/angular.js')

    }}"></script> <script src="{{ url_for('static', filename='vendor/angular-route/angular-route.js') }}"></script> <script src="{{ url_for('static', filename='js/app.js') }}"></script> <script src="{{ url_for('static', filename='js/controllers.js') }}"></script> <!-- ... -->
  25. $ less blog/api/post_details.py from blog.api.base import Resource from blog.models import

    Post class PostDetailsResource(Resource): def get(self, post_id): """ Return information about a given post. """ post = Post.query.get(post_id) if post is None: return '', 404 return { 'id': post.id, 'title': post.title, 'body': post.body, 'pubDate': post.pub_date.isoformat(), }
  26. $ vi tests/python/api/test_post_details.py import json from blog.config import db from

    blog.models import Post def test_valid_post_on_get(client): post = Post( title='Hello world!', body='Lorem ipsum dolor sit amet, consectetur adipiscing elit.', ) db.session.add(post) db.session.commit() resp = client.get('/api/0/posts/{0}/'.format(post.id)) assert resp.status_code == 200 assert resp.headers['Content-Type'] == 'application/json', resp.data assert json.loads(resp.data.decode('utf-8')) == { 'id': post.id, 'title': post.title, 'body': post.body, 'pubDate': post.pub_date.isoformat(), }
  27. $ vi static/js/controllers.js angular.module('blog.controllers', ['ngRoute']) .controller('PostListCtrl', function($scope, $http){ $http.get('/api/0/posts/') .success(function(data){

    $scope.postList = data; }); }); .controller('PostDetailsCtrl', function($scope, $routeParams, $http){ $http.get('/api/0/posts/' + $routeParams.post_id + '/') .success(function(data){ $scope.post = data; }); });
  28. $ vi static/js/app.js $routeProvider .when('/', { templateUrl: 'post-list.html', controller: 'PostListCtrl'

    }) .when('/posts/:post_id', { templateUrl: 'post-details.html', controller: 'PostDetailsCtrl' });
  29. $ vi static/js/app.js .when('/', { templateUrl: 'post-list.html', controller: 'PostListCtrl', resolve:

    { postListResponse: function($http) { return $http.get('/api/0/posts/'); } } })
  30. $ vi static/js/app.js .when('/posts/:post_id', { templateUrl: 'post-details.html', controller: 'PostDetailsCtrl', resolve:

    { postDetailsResponse: function($http, $route) { var postId = $route.current.params.post_id; return $http.get('/api/0/posts/' + postId + '/'); } } });
  31. $ vi static/js/controllers.js angular.module('blog.controllers', []) .controller('PostListCtrl', function($scope, postListResponse){ $scope.postList =

    postListResponse.data; }) .controller('PostDetailsCtrl', function($scope, postDetailsResponse){ $scope.post = postDetailsResponse.data; });
  32. $ vi tests/js/controllersSpec.js it('should bind postList', inject(function($controller){ var samplePost =

    {id: 1, title: 'Test', body: 'Foo bar'}; var $scope = {}; var ctrl = $controller('PostListCtrl', { $scope: $scope, postListResponse: {data: [samplePost]} }); expect($scope.postList.length).toBe(1); expect($scope.postList[0].id).toBe(samplePost.id); }));
  33. $ vi tests/js/controllersSpec.js describe('PostDetailsCtrl', function(){ it('should bind post', inject(function($controller){ var

    samplePost = {id: 1, title: 'Test', body: 'Foo bar'}; var $scope = {}; var ctrl = $controller('PostDetailsCtrl', { $scope: $scope, postDetailsResponse: {data: samplePost} }); expect($scope.post.id).toBe(samplePost.id); })); });
  34. $ vi blog/api/post_index.py from flask.ext.restful.reqparse import RequestParser from blog.api.base import

    Resource from blog.config import db from blog.models import Post class PostIndexResource(Resource): def get(self): # ... def post(self): """ Create a new post. """ parser = RequestParser() parser.add_argument('title', required=True) parser.add_argument('body', required=True) args = parser.parse_args() post = Post( title=args.title, body=args.body, ) db.session.add(post) db.session.commit() return { 'id': post.id, 'title': post.title, 'body': post.body, 'pubDate': post.pub_date.isoformat(), }, 201
  35. $ vi tests/python/api/test_post_index.py def test_list(client): # ... def test_create_with_valid_params(client): title

    = 'Foo' body = 'Bar' # valid params resp = client.post('/api/0/posts/', data={ 'title': title, 'body': body, }, follow_redirects=True) assert resp.status_code == 201 data = json.loads(resp.data.decode('utf-8')) assert data['title'] == title assert data['body'] == body
  36. $ vi static/js/controllers.js angular.module('blog.controllers', ['ngRoute']) .controller('PostListCtrl', function($scope, postListResponse){ $scope.postList =

    postListResponse.data; }); .controller('PostDetailsCtrl', function($scope, postDetailsResponse){ $scope.post = postDetailsResponse.data; }) .controller('NewPostCtrl', function($location, $scope, $http){ $scope.formData = {}; $scope.saveForm = function(){ $http.post('/api/0/posts/' $scope.formData) .success(function(data){ $location.path('/posts/' + data.id); }); }; });
  37. $ vi tests/js/controllersSpec.js describe('NewPostCtrl', function(){ var $httpBackend, samplePost; beforeEach(inject(function($injector){ samplePost

    = {id: 1, title: 'Test', body: 'Foo bar'}; $httpBackend = $injector.get('$httpBackend'); $httpBackend.when('POST', '/api/0/posts/').respond(samplePost); })); it('should support saveForm', inject(function($controller){ // TODO })); afterEach(function() { $httpBackend.verifyNoOutstandingExpectation(); $httpBackend.verifyNoOutstandingRequest(); }); });
  38. $ vi tests/js/controllersSpec.js it('should support saveForm', inject(function($controller){ var $scope =

    {formData: samplePost}; var ctrl = $controller('NewPostCtrl', { $scope: $scope }); $httpBackend.expectPOST('/api/0/posts/', $scope.formData) .respond(201, samplePost); $scope.saveForm(); $httpBackend.flush(); }));
  39. $ vi static/partials/new-post.html <form role="role" name="postForm" ng-submit="saveForm()"> <input type="text" name="title"

    ng-model="formData.title" ng-class="{error: !postForm.title.$valid}" class="form-control" required> <textarea name="body" ng-model="formData.body" ng-class="{error: !postForm.body.$valid}" class="form-control" required></textarea> <button class="btn" type="submit" ng-disabled="postForm.$invalid">Publish</button> </form>
  40. $ vi static/js/app.js $routeProvider .when('/', { // ... }) .when('/posts/:post_id',

    { // ... }) .when('/new/post', { templateUrl: 'new-post.html', controller: 'NewPostCtrl' });
  41. # element-based directives # do not work in Internet #

    Explorer <directive-name foo="bar">
  42. $ less templates/index.html <!-- ... --> <script src="{{ url_for('static', filename='vendor/angular/angular.js')

    }}"></script> <script src="{{ url_for('static', filename='vendor/angular-route/angular-route.js') }}"></script> <script src="{{ url_for('static', filename='vendor/angular-sanitize/angular-sanitize.js') }}"></script> <script src="{{ url_for('static', filename='vendor/showdown/src/showdown.js') }}"></script> <!-- ... -->
  43. $ vi static/js/directives.js angular.module('blog.directives.markdown', ['ngSanitize']). directive('markdown', function($sanitize) { var markdownConverter

    = new Showdown.converter(); return { restrict: 'A', link: function (scope, element, attrs) { scope.$watch(attrs.markdown, function(newVal) { var html = $sanitize(markdownConverter.makeHtml(newVal)); element.html(html); }); } }; });
  44. $ less templates/index.html <!-- ... --> <script src="{{ url_for('static', filename='js/app.js')

    }}"></script> <script src="{{ url_for('static', filename='js/controllers.js') }}"></script> <script src="{{ url_for('static', filename='js/directives.js') }}"></script> <!-- ... -->
  45. $ vi static/partials/post-list.html <ul> <li ng-repeat="post in postList"> <a href="/#/posts/{{

    post.id }}">{{ post.title }}</a> &mdash; <span time-since="post.pubDate"></span> </li> </ul>
  46. $ vi static/js/directives.js 'use strict'; angular.module('blog.directives.timeSince', []) .directive('timeSince', function($timeout) {

    return function(scope, element, attrs) { var $element = angular.element(element), timeout_id; function updateValue(){ var value = scope.$eval(attrs.timeSince); if (value) { element.text(moment.utc(value).fromNow()); } else { element.text(''); } timeout_id = $timeout(updateValue, 1000); } element.bind('$destroy', function() { $timeout.cancel(timeout_id); }); updateValue(); }; });
  47. $ less templates/index.html <!-- ... --> <script src="{{ url_for('static', filename='vendor/angular/angular.js')

    }}"></script> <script src="{{ url_for('static', filename='vendor/angular-route/angular-route.js') }}"></script> <script src="{{ url_for('static', filename='vendor/angular-sanitize/angular-sanitize.js') }}"></script> <script src="{{ url_for('static', filename='vendor/showdown/src/showdown.js') }}"></script> <script src="{{ url_for('static', filename='vendor/moment/moment.js') }}"></script> <!-- ... -->
  48. $ vi static/js/directives.js .directive('timeSince', function($timeout) { return function(scope, element, attrs)

    { // ... function timeUntilTick(age) { if (age < 1) { return 1; } else if (age < 60) { return 30; } else if (age < 180) { return 300; } else { return 3600; } } function updateValue(){ // ... var age = moment().diff(moment.utc(value), 'minute'); timeout_id = $timeout(updateValue, timeUntilTick(age) * 1000); }
  49. $ vi static/js/services.js angular.module('blog.services.api', []) .factory('api', function($http){ var api =

    {}, urlBase = '/api/0'; api.listPosts = function(){ return $http.get(urlBase + '/posts/'); }; api.createPost = function(data){ return $http.post(urlBase + '/posts/', data); }; api.getPost = function(id){ return $http.get(urlBase + '/posts/' + id + '/'); }; api.updatePost = function(id, data){ return $http.post(urlBase + '/posts/' + id + '/', data); }; return api; });
  50. $ less templates/index.html <!-- ... --> <script src="{{ url_for('static', filename='js/app.js')

    }}"></script> <script src="{{ url_for('static', filename='js/controllers.js') }}"></script> <script src="{{ url_for('static', filename='js/directives.js') }}"></script> <script src="{{ url_for('static', filename='js/services.js') }}"></script> <!-- ... -->
  51. $ vi static/js/controllers.js angular.module('blog.controllers', ['ngRoute']) .controller('PostListCtrl', function($scope, postListResponse){ $scope.postList =

    postListResponse.data; }); .controller('PostDetailsCtrl', function($scope, postDetailsResponse){ $scope.post = postDetailsResponse.data; }) .controller('NewPostCtrl', function($location, $scope, api){ $scope.formData = {}; $scope.saveForm = function(){ api.createPost($scope.formData) .success(function(data){ $location.path('/posts/' + data.id); }); }; });
  52. $ vi static/js/controllers.js angular.module('blog.controllers', []) .controller('PostListCtrl', function($scope, $timeout, api, postListResponse){

    var timeout_id; $scope.postList = postListResponse.data; $timeout(function(){ api.listPosts().success(function(data){ $scope.postList = data; }); }, 5000); $scope.$on('$destroy', function(){ $timeout.cancel(timeout_id); }); }) .controller('PostDetailsCtrl', function($scope, postDetailsResponse){ $scope.post = postDetailsResponse.data; });
  53. $ vi static/js/services.js angular.module('blog.services.loadingIndicator', []) .config(function($httpProvider){ var interceptor = function($q,

    $rootScope) { var reqsTotal = 0; var reqsCompleted = 0; return { request: function(config) { $rootScope.loading = true; reqsTotal++; return config; }, response: function(response) { reqsCompleted++; if (reqsCompleted >= reqsTotal) { $rootScope.loading = false; } return response; }, responseError: function(rejection) { reqsCompleted++; if (reqsCompleted >= reqsTotal) { $rootScope.loading = false; } return $q.reject(rejection); } }; }; $httpProvider.interceptors.push(interceptor); });
  54. $ vi static/js/app.js 'use strict'; angular.module('blog', [ 'ngRoute', 'blog.controllers', 'blog.directives.timeSince',

    'blog.services.api', 'blog.services.loadingIndicator' ]).config(function($routeProvider) { // ... });
  55. $ vi tests/protractor.conf.js exports.config = { allScriptsTimeout: 11000, specs: [

    'e2e/*.js' ], capabilities: { 'browserName': 'chrome' }, baseUrl: 'http://localhost:5000/', framework: 'jasmine', jasmineNodeOpts: { defaultTimeoutInterval: 30000 } };
  56. $ vi tests/e2e/scenarios.js 'use strict'; describe('blog', function() { browser.get('/'); describe('index',

    function() { beforeEach(function() { browser.get('/#/'); }); it('should render the post list', function() { expect(element.all(by.css('[ng-view] a')).first().getText()). toMatch(/Hello world \(again\)\!/); }); }); });
  57. $ vi package.json { // ... "scripts": { "postinstall": "bower

    install", "pretest": "npm install", "test": "karma start tests/karma.conf.js", "preupdate-webdriver": "npm install", "update-webdriver": "webdriver-manager update", "preprotractor": "npm run update-webdriver", "protractor": "protractor tests/protractor.conf.js" } }