From f94b112589340d9839e58942115d9d874bb9578b Mon Sep 17 00:00:00 2001 From: Stephen Sawchuk Date: Mon, 3 Nov 2014 09:46:06 -0500 Subject: [PATCH] Implement backend API to Angular. --- examples/angularjs/bower.json | 2 +- .../bower_components/todomvc-common/base.js | 35 +++- examples/angularjs/index.html | 8 +- examples/angularjs/js/app.js | 27 ++- examples/angularjs/js/controllers/todoCtrl.js | 91 ++++++--- examples/angularjs/js/services/todoStorage.js | 175 +++++++++++++++++- examples/angularjs/test/unit/todoCtrlSpec.js | 87 ++++----- learn.json | 2 +- package.json | 21 ++- server.js | 20 ++ 10 files changed, 363 insertions(+), 105 deletions(-) create mode 100644 server.js diff --git a/examples/angularjs/bower.json b/examples/angularjs/bower.json index b16cbb4dde..62f19e178c 100644 --- a/examples/angularjs/bower.json +++ b/examples/angularjs/bower.json @@ -3,7 +3,7 @@ "version": "0.0.0", "dependencies": { "angular": "1.3.0", - "todomvc-common": "~0.1.4" + "todomvc-common": "~0.3.0" }, "devDependencies": { "angular-mocks": "1.3.0", diff --git a/examples/angularjs/bower_components/todomvc-common/base.js b/examples/angularjs/bower_components/todomvc-common/base.js index dd2eba3ef2..d3a15127d8 100644 --- a/examples/angularjs/bower_components/todomvc-common/base.js +++ b/examples/angularjs/bower_components/todomvc-common/base.js @@ -171,25 +171,42 @@ framework = document.querySelector('[data-framework]').dataset.framework; } + this.template = template; - if (template && learnJSON[framework]) { + if (learnJSON.backend) { + this.frameworkJSON = learnJSON.backend; + this.append({ + backend: true + }); + } else if (learnJSON[framework]) { this.frameworkJSON = learnJSON[framework]; - this.template = template; - this.append(); } } - Learn.prototype.append = function () { + Learn.prototype.append = function (opts) { var aside = document.createElement('aside'); aside.innerHTML = _.template(this.template, this.frameworkJSON); aside.className = 'learn'; - // Localize demo links - var demoLinks = aside.querySelectorAll('.demo-link'); - Array.prototype.forEach.call(demoLinks, function (demoLink) { - demoLink.setAttribute('href', findRoot() + demoLink.getAttribute('href')); - }); + if (opts && opts.backend) { + // Remove demo link + var sourceLinks = aside.querySelector('.source-links'); + var heading = sourceLinks.firstElementChild; + var sourceLink = sourceLinks.lastElementChild; + // Correct link path + var href = sourceLink.getAttribute('href'); + sourceLink.setAttribute('href', href.substr(href.lastIndexOf('http'))); + sourceLinks.innerHTML = heading.outerHTML + sourceLink.outerHTML; + } else { + // Localize demo links + var demoLinks = aside.querySelectorAll('.demo-link'); + Array.prototype.forEach.call(demoLinks, function (demoLink) { + if (demoLink.getAttribute('href').substr(0, 4) !== 'http') { + demoLink.setAttribute('href', findRoot() + demoLink.getAttribute('href')); + } + }); + } document.body.className = (document.body.className + ' learn-bar').trim(); document.body.insertAdjacentHTML('afterBegin', aside.outerHTML); diff --git a/examples/angularjs/index.html b/examples/angularjs/index.html index ec2b1776ba..d13820c5ed 100644 --- a/examples/angularjs/index.html +++ b/examples/angularjs/index.html @@ -14,7 +14,7 @@
@@ -23,12 +23,12 @@

todos

diff --git a/examples/angularjs/js/app.js b/examples/angularjs/js/app.js index be2fda9101..d6dc1dba4b 100644 --- a/examples/angularjs/js/app.js +++ b/examples/angularjs/js/app.js @@ -9,13 +9,24 @@ angular.module('todomvc', ['ngRoute']) .config(function ($routeProvider) { 'use strict'; - $routeProvider.when('/', { + var routeConfig = { controller: 'TodoCtrl', - templateUrl: 'todomvc-index.html' - }).when('/:status', { - controller: 'TodoCtrl', - templateUrl: 'todomvc-index.html' - }).otherwise({ - redirectTo: '/' - }); + templateUrl: 'todomvc-index.html', + resolve: { + store: function (todoStorage) { + // Get the correct module (API or localStorage). + return todoStorage.then(function (module) { + module.get(); // Fetch the todo records in the background. + return module; + }); + } + } + }; + + $routeProvider + .when('/', routeConfig) + .when('/:status', routeConfig) + .otherwise({ + redirectTo: '/' + }); }); diff --git a/examples/angularjs/js/controllers/todoCtrl.js b/examples/angularjs/js/controllers/todoCtrl.js index a54cd768d2..e676cadf4a 100644 --- a/examples/angularjs/js/controllers/todoCtrl.js +++ b/examples/angularjs/js/controllers/todoCtrl.js @@ -6,21 +6,18 @@ * - exposes the model to the template and provides event handlers */ angular.module('todomvc') - .controller('TodoCtrl', function TodoCtrl($scope, $routeParams, $filter, todoStorage) { + .controller('TodoCtrl', function TodoCtrl($scope, $routeParams, $filter, store) { 'use strict'; - var todos = $scope.todos = todoStorage.get(); + var todos = $scope.todos = store.todos; $scope.newTodo = ''; $scope.editedTodo = null; - $scope.$watch('todos', function (newValue, oldValue) { + $scope.$watch('todos', function () { $scope.remainingCount = $filter('filter')(todos, { completed: false }).length; $scope.completedCount = todos.length - $scope.remainingCount; $scope.allChecked = !$scope.remainingCount; - if (newValue !== oldValue) { // This prevents unneeded calls to the local storage - todoStorage.put(todos); - } }, true); // Monitor the current route for changes and adjust the filter accordingly. @@ -33,17 +30,23 @@ angular.module('todomvc') }); $scope.addTodo = function () { - var newTodo = $scope.newTodo.trim(); - if (!newTodo.length) { + var newTodo = { + title: $scope.newTodo.trim(), + completed: false + }; + + if (!newTodo.title) { return; } - todos.push({ - title: newTodo, - completed: false - }); - - $scope.newTodo = ''; + $scope.saving = true; + store.insert(newTodo) + .then(function success() { + $scope.newTodo = ''; + }) + .finally(function () { + $scope.saving = false; + }); }; $scope.editTodo = function (todo) { @@ -52,33 +55,71 @@ angular.module('todomvc') $scope.originalTodo = angular.extend({}, todo); }; - $scope.doneEditing = function (todo) { - $scope.editedTodo = null; + $scope.saveEdits = function (todo, event) { + // Blur events are automatically triggered after the form submit event. + // This does some unfortunate logic handling to prevent saving twice. + if (event === 'blur' && $scope.saveEvent === 'submit') { + $scope.saveEvent = null; + return; + } + + $scope.saveEvent = event; + + if ($scope.reverted) { + // Todo edits were reverted-- don't save. + $scope.reverted = null; + return; + } + todo.title = todo.title.trim(); - if (!todo.title) { - $scope.removeTodo(todo); + if (todo.title === $scope.originalTodo.title) { + return; } + + store[todo.title ? 'put' : 'delete'](todo) + .then(function success() {}, function error() { + todo.title = $scope.originalTodo.title; + }) + .finally(function () { + $scope.editedTodo = null; + }); }; - $scope.revertEditing = function (todo) { + $scope.revertEdits = function (todo) { todos[todos.indexOf(todo)] = $scope.originalTodo; - $scope.doneEditing($scope.originalTodo); + $scope.editedTodo = null; + $scope.originalTodo = null; + $scope.reverted = true; }; $scope.removeTodo = function (todo) { - todos.splice(todos.indexOf(todo), 1); + store.delete(todo); + }; + + $scope.saveTodo = function (todo) { + store.put(todo); + }; + + $scope.toggleCompleted = function (todo, completed) { + if (angular.isDefined(completed)) { + todo.completed = completed; + } + store.put(todo, todos.indexOf(todo)) + .then(function success() {}, function error() { + todo.completed = !todo.completed; + }); }; $scope.clearCompletedTodos = function () { - $scope.todos = todos = todos.filter(function (val) { - return !val.completed; - }); + store.clearCompleted(); }; $scope.markAll = function (completed) { todos.forEach(function (todo) { - todo.completed = !completed; + if (todo.completed !== completed) { + $scope.toggleCompleted(todo, completed); + } }); }; }); diff --git a/examples/angularjs/js/services/todoStorage.js b/examples/angularjs/js/services/todoStorage.js index e8775043dc..bce578a34e 100644 --- a/examples/angularjs/js/services/todoStorage.js +++ b/examples/angularjs/js/services/todoStorage.js @@ -1,21 +1,186 @@ /*global angular */ /** - * Services that persists and retrieves TODOs from localStorage + * Services that persists and retrieves todos from localStorage or a backend API + * if available. + * + * They both follow the same API, returning promises for all changes to the + * model. */ angular.module('todomvc') - .factory('todoStorage', function () { + .factory('todoStorage', function ($http, $injector) { 'use strict'; - var STORAGE_ID = 'todos-angularjs'; + // Detect if an API backend is present. If so, return the API module, else + // hand off the localStorage adapter + return $http.head('/api') + .then(function () { + return $injector.get('api'); + }, function () { + return $injector.get('localStorage'); + }); + }) + + .factory('api', function ($http) { + 'use strict'; + + var store = { + todos: [], + + clearCompleted: function () { + var originalTodos = store.todos.slice(0); + + var completeTodos = [], incompleteTodos = []; + store.todos.forEach(function (todo) { + if (todo.completed) { + completeTodos.push(todo); + } else { + incompleteTodos.push(todo); + } + }); + + angular.copy(incompleteTodos, store.todos); + + return $http.delete('/api/todos') + .then(function success() { + return store.todos; + }, function error() { + angular.copy(originalTodos, store.todos); + return originalTodos; + }); + }, + + delete: function (todo) { + var originalTodos = store.todos.slice(0); + + store.todos.splice(store.todos.indexOf(todo), 1); + + return $http.delete('/api/todos/' + todo.id) + .then(function success() { + return store.todos; + }, function error() { + angular.copy(originalTodos, store.todos); + return originalTodos; + }); + }, - return { get: function () { + return $http.get('/api/todos') + .then(function (resp) { + angular.copy(resp.data, store.todos); + return store.todos; + }); + }, + + insert: function (todo) { + var originalTodos = store.todos.slice(0); + + store.todos.push(todo); + + return $http.post('/api/todos', todo) + .then(function success(resp) { + todo.id = resp.data.id; + return store.todos; + }, function error() { + angular.copy(originalTodos, store.todos); + return store.todos; + }); + }, + + put: function (todo) { + var originalTodos = store.todos.slice(0); + + return $http.put('/api/todos/' + todo.id, todo) + .then(function success() { + return store.todos; + }, function error() { + angular.copy(originalTodos, store.todos); + return originalTodos; + }); + } + }; + + return store; + }) + + .factory('localStorage', function ($q) { + 'use strict'; + + var STORAGE_ID = 'todos-angularjs'; + + var store = { + todos: [], + + _getFromLocalStorage: function () { return JSON.parse(localStorage.getItem(STORAGE_ID) || '[]'); }, - put: function (todos) { + _saveToLocalStorage: function (todos) { localStorage.setItem(STORAGE_ID, JSON.stringify(todos)); + }, + + clearCompleted: function () { + var deferred = $q.defer(); + + var completeTodos = [], incompleteTodos = []; + store.todos.forEach(function (todo) { + if (todo.completed) { + completeTodos.push(todo); + } else { + incompleteTodos.push(todo); + } + }); + + angular.copy(incompleteTodos, store.todos); + + store._saveToLocalStorage(store.todos); + deferred.resolve(store.todos); + + return deferred.promise; + }, + + delete: function (todo) { + var deferred = $q.defer(); + + store.todos.splice(store.todos.indexOf(todo), 1); + + store._saveToLocalStorage(store.todos); + deferred.resolve(store.todos); + + return deferred.promise; + }, + + get: function () { + var deferred = $q.defer(); + + angular.copy(store._getFromLocalStorage(), store.todos); + deferred.resolve(store.todos); + + return deferred.promise; + }, + + insert: function (todo) { + var deferred = $q.defer(); + + store.todos.push(todo); + + store._saveToLocalStorage(store.todos); + deferred.resolve(store.todos); + + return deferred.promise; + }, + + put: function (todo, index) { + var deferred = $q.defer(); + + store.todos[index] = todo; + + store._saveToLocalStorage(store.todos); + deferred.resolve(store.todos); + + return deferred.promise; } }; + + return store; }); diff --git a/examples/angularjs/test/unit/todoCtrlSpec.js b/examples/angularjs/test/unit/todoCtrlSpec.js index cd292b06be..3d47bbd196 100644 --- a/examples/angularjs/test/unit/todoCtrlSpec.js +++ b/examples/angularjs/test/unit/todoCtrlSpec.js @@ -3,24 +3,28 @@ 'use strict'; describe('Todo Controller', function () { - var ctrl, scope; - var todoList; - var todoStorage = { - storage: {}, - get: function () { - return this.storage; - }, - put: function (value) { - this.storage = value; - } - }; - - // Load the module containing the app, only 'ng' is loaded by default. + var ctrl, scope, store; + + // Load the module containing the app, only 'ng' is loaded by default. beforeEach(module('todomvc')); - beforeEach(inject(function ($controller, $rootScope) { + beforeEach(inject(function ($controller, $rootScope, localStorage) { scope = $rootScope.$new(); - ctrl = $controller('TodoCtrl', { $scope: scope }); + + store = localStorage; + + localStorage.todos = []; + localStorage._getFromLocalStorage = function () { + return []; + }; + localStorage._saveToLocalStorage = function (todos) { + localStorage.todos = todos; + }; + + ctrl = $controller('TodoCtrl', { + $scope: scope, + store: store + }); })); it('should not have an edited Todo on start', function () { @@ -48,6 +52,7 @@ it('should filter non-completed', inject(function ($controller) { ctrl = $controller('TodoCtrl', { $scope: scope, + store: store, $routeParams: { status: 'active' } @@ -64,7 +69,8 @@ $scope: scope, $routeParams: { status: 'completed' - } + }, + store: store }); scope.$emit('$routeChangeSuccess'); @@ -77,10 +83,9 @@ var ctrl; beforeEach(inject(function ($controller) { - todoStorage.storage = []; ctrl = $controller('TodoCtrl', { $scope: scope, - todoStorage: todoStorage + store: store }); scope.$digest(); })); @@ -113,28 +118,16 @@ var ctrl; beforeEach(inject(function ($controller) { - todoList = [{ - 'title': 'Uncompleted Item 0', - 'completed': false - }, { - 'title': 'Uncompleted Item 1', - 'completed': false - }, { - 'title': 'Uncompleted Item 2', - 'completed': false - }, { - 'title': 'Completed Item 0', - 'completed': true - }, { - 'title': 'Completed Item 1', - 'completed': true - }]; - - todoStorage.storage = todoList; ctrl = $controller('TodoCtrl', { $scope: scope, - todoStorage: todoStorage + store: store }); + + store.insert({ title: 'Uncompleted Item 0', completed: false }); + store.insert({ title: 'Uncompleted Item 1', completed: false }); + store.insert({ title: 'Uncompleted Item 2', completed: false }); + store.insert({ title: 'Completed Item 0', completed: true }) + store.insert({ title: 'Completed Item 1', completed: true }) scope.$digest(); })); @@ -146,22 +139,22 @@ }); it('should save Todos to local storage', function () { - expect(todoStorage.storage.length).toBe(5); + expect(scope.todos.length).toBe(5); }); it('should remove Todos w/o title on saving', function () { - var todo = todoList[2]; + var todo = store.todos[2]; + scope.editTodo(todo); todo.title = ''; - - scope.doneEditing(todo); + scope.saveEdits(todo); expect(scope.todos.length).toBe(4); }); it('should trim Todos on saving', function () { - var todo = todoList[0]; + var todo = store.todos[0]; + scope.editTodo(todo); todo.title = ' buy moar unicorns '; - - scope.doneEditing(todo); + scope.saveEdits(todo); expect(scope.todos[0].title).toBe('buy moar unicorns'); }); @@ -171,16 +164,16 @@ }); it('markAll() should mark all Todos completed', function () { - scope.markAll(); + scope.markAll(true); scope.$digest(); expect(scope.completedCount).toBe(5); }); it('revertTodo() get a Todo to its previous state', function () { - var todo = todoList[0]; + var todo = store.todos[0]; scope.editTodo(todo); todo.title = 'Unicorn sparkly skypuffles.'; - scope.revertEditing(todo); + scope.revertEdits(todo); scope.$digest(); expect(scope.todos[0].title).toBe('Uncompleted Item 0'); }); diff --git a/learn.json b/learn.json index 5550a9682c..ac814c9d9b 100644 --- a/learn.json +++ b/learn.json @@ -2265,6 +2265,6 @@ }] }, "templates": { - "todomvc": "

<%= name %>

<% if (typeof examples !== 'undefined') { %> <% examples.forEach(function (example) { %>
<%= example.name %>
<% if (!location.href.match(example.url + '/')) { %> \">Demo, <% } %> \">Source <% }); %> <% } %>

<%= description %>

<% if (typeof link_groups !== 'undefined') { %>
<% link_groups.forEach(function (link_group) { %>

<%= link_group.heading %>

<% }); %> <% } %>

If you have other helpful links to share, or find any of the links above no longer work, please let us know.
" + "todomvc": "

<%= name %>

<% if (typeof examples !== 'undefined') { %> <% examples.forEach(function (example) { %>
<%= example.name %>
<% if (!location.href.match(example.url + '/')) { %> \" href=\"<%= example.url %>\">Demo, <% } if (example.type === 'backend') { %>\"><% } else { %>\"><% } %>Source <% }); %> <% } %>

<%= description %>

<% if (typeof link_groups !== 'undefined') { %>
<% link_groups.forEach(function (link_group) { %>

<%= link_group.heading %>

<% }); %> <% } %> " } } diff --git a/package.json b/package.json index d48e1ff759..eb21b2b527 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,19 @@ { + "name": "todomvc", + "version": "0.0.0", + "main": "server.js", + "files": [ + "license.md", + "server.js", + "examples", + "media", + "site-assets", + "index.html", + "learn.json" + ], + "dependencies": { + "express": "^4.10.0" + }, "devDependencies": { "del": "^0.1.1", "gulp": "^3.8.5", @@ -19,9 +34,5 @@ "jshint-stylish": "^1.0.0", "psi": "^0.1.1", "run-sequence": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - }, - "private": true + } } diff --git a/server.js b/server.js new file mode 100644 index 0000000000..3c5939a87f --- /dev/null +++ b/server.js @@ -0,0 +1,20 @@ +'use strict'; + +var express = require('express'); +var fs = require('fs'); +var learnJson = require('./learn.json'); + +var app = module.exports = express(); + +app.use(express.static(__dirname)); + +Object.defineProperty(module.exports, 'learnJson', { + set: function (backend) { + learnJson.backend = backend; + fs.writeFile(require.resolve('./learn.json'), JSON.stringify(learnJson, null, 2), function (err) { + if (err) { + throw err; + } + }); + } +});