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

Angular.js Workshop (PyCon SG 2014)

Sponsored · Ship Features Fearlessly Turn features on and off without deploys. Used by thousands of Ruby developers.

Angular.js Workshop (PyCon SG 2014)

Avatar for David Cramer

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" } }