Commit f5bdf3af authored by Stephen Sawchuk's avatar Stephen Sawchuk

Merge pull request #1050 from stephenplusplus/be--angular

Implement backend API to Angular.
parents 258e1720 f94b1125
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"angular": "1.3.0", "angular": "1.3.0",
"todomvc-common": "~0.1.4" "todomvc-common": "~0.3.0"
}, },
"devDependencies": { "devDependencies": {
"angular-mocks": "1.3.0", "angular-mocks": "1.3.0",
......
...@@ -171,25 +171,42 @@ ...@@ -171,25 +171,42 @@
framework = document.querySelector('[data-framework]').dataset.framework; 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.frameworkJSON = learnJSON[framework];
this.template = template;
this.append(); this.append();
} }
} }
Learn.prototype.append = function () { Learn.prototype.append = function (opts) {
var aside = document.createElement('aside'); var aside = document.createElement('aside');
aside.innerHTML = _.template(this.template, this.frameworkJSON); aside.innerHTML = _.template(this.template, this.frameworkJSON);
aside.className = 'learn'; aside.className = 'learn';
// Localize demo links if (opts && opts.backend) {
var demoLinks = aside.querySelectorAll('.demo-link'); // Remove demo link
Array.prototype.forEach.call(demoLinks, function (demoLink) { var sourceLinks = aside.querySelector('.source-links');
demoLink.setAttribute('href', findRoot() + demoLink.getAttribute('href')); 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.className = (document.body.className + ' learn-bar').trim();
document.body.insertAdjacentHTML('afterBegin', aside.outerHTML); document.body.insertAdjacentHTML('afterBegin', aside.outerHTML);
......
...@@ -14,7 +14,7 @@ ...@@ -14,7 +14,7 @@
<header id="header"> <header id="header">
<h1>todos</h1> <h1>todos</h1>
<form id="todo-form" ng-submit="addTodo()"> <form id="todo-form" ng-submit="addTodo()">
<input id="new-todo" placeholder="What needs to be done?" ng-model="newTodo" autofocus> <input id="new-todo" placeholder="What needs to be done?" ng-model="newTodo" ng-disabled="saving" autofocus>
</form> </form>
</header> </header>
<section id="main" ng-show="todos.length" ng-cloak> <section id="main" ng-show="todos.length" ng-cloak>
...@@ -23,12 +23,12 @@ ...@@ -23,12 +23,12 @@
<ul id="todo-list"> <ul id="todo-list">
<li ng-repeat="todo in todos | filter:statusFilter track by $index" ng-class="{completed: todo.completed, editing: todo == editedTodo}"> <li ng-repeat="todo in todos | filter:statusFilter track by $index" ng-class="{completed: todo.completed, editing: todo == editedTodo}">
<div class="view"> <div class="view">
<input class="toggle" type="checkbox" ng-model="todo.completed"> <input class="toggle" type="checkbox" ng-model="todo.completed" ng-change="toggleCompleted(todo)">
<label ng-dblclick="editTodo(todo)">{{todo.title}}</label> <label ng-dblclick="editTodo(todo)">{{todo.title}}</label>
<button class="destroy" ng-click="removeTodo(todo)"></button> <button class="destroy" ng-click="removeTodo(todo)"></button>
</div> </div>
<form ng-submit="doneEditing(todo)"> <form ng-submit="saveEdits(todo, 'submit')">
<input class="edit" ng-trim="false" ng-model="todo.title" todo-escape="revertEditing(todo)" ng-blur="doneEditing(todo)" todo-focus="todo == editedTodo"> <input class="edit" ng-trim="false" ng-model="todo.title" todo-escape="revertEdits(todo)" ng-blur="saveEdits(todo, 'blur')" todo-focus="todo == editedTodo">
</form> </form>
</li> </li>
</ul> </ul>
......
...@@ -9,13 +9,24 @@ angular.module('todomvc', ['ngRoute']) ...@@ -9,13 +9,24 @@ angular.module('todomvc', ['ngRoute'])
.config(function ($routeProvider) { .config(function ($routeProvider) {
'use strict'; 'use strict';
$routeProvider.when('/', { var routeConfig = {
controller: 'TodoCtrl', controller: 'TodoCtrl',
templateUrl: 'todomvc-index.html' templateUrl: 'todomvc-index.html',
}).when('/:status', { resolve: {
controller: 'TodoCtrl', store: function (todoStorage) {
templateUrl: 'todomvc-index.html' // Get the correct module (API or localStorage).
}).otherwise({ return todoStorage.then(function (module) {
redirectTo: '/' module.get(); // Fetch the todo records in the background.
}); return module;
});
}
}
};
$routeProvider
.when('/', routeConfig)
.when('/:status', routeConfig)
.otherwise({
redirectTo: '/'
});
}); });
...@@ -6,21 +6,18 @@ ...@@ -6,21 +6,18 @@
* - exposes the model to the template and provides event handlers * - exposes the model to the template and provides event handlers
*/ */
angular.module('todomvc') angular.module('todomvc')
.controller('TodoCtrl', function TodoCtrl($scope, $routeParams, $filter, todoStorage) { .controller('TodoCtrl', function TodoCtrl($scope, $routeParams, $filter, store) {
'use strict'; 'use strict';
var todos = $scope.todos = todoStorage.get(); var todos = $scope.todos = store.todos;
$scope.newTodo = ''; $scope.newTodo = '';
$scope.editedTodo = null; $scope.editedTodo = null;
$scope.$watch('todos', function (newValue, oldValue) { $scope.$watch('todos', function () {
$scope.remainingCount = $filter('filter')(todos, { completed: false }).length; $scope.remainingCount = $filter('filter')(todos, { completed: false }).length;
$scope.completedCount = todos.length - $scope.remainingCount; $scope.completedCount = todos.length - $scope.remainingCount;
$scope.allChecked = !$scope.remainingCount; $scope.allChecked = !$scope.remainingCount;
if (newValue !== oldValue) { // This prevents unneeded calls to the local storage
todoStorage.put(todos);
}
}, true); }, true);
// Monitor the current route for changes and adjust the filter accordingly. // Monitor the current route for changes and adjust the filter accordingly.
...@@ -33,17 +30,23 @@ angular.module('todomvc') ...@@ -33,17 +30,23 @@ angular.module('todomvc')
}); });
$scope.addTodo = function () { $scope.addTodo = function () {
var newTodo = $scope.newTodo.trim(); var newTodo = {
if (!newTodo.length) { title: $scope.newTodo.trim(),
completed: false
};
if (!newTodo.title) {
return; return;
} }
todos.push({ $scope.saving = true;
title: newTodo, store.insert(newTodo)
completed: false .then(function success() {
}); $scope.newTodo = '';
})
$scope.newTodo = ''; .finally(function () {
$scope.saving = false;
});
}; };
$scope.editTodo = function (todo) { $scope.editTodo = function (todo) {
...@@ -52,33 +55,71 @@ angular.module('todomvc') ...@@ -52,33 +55,71 @@ angular.module('todomvc')
$scope.originalTodo = angular.extend({}, todo); $scope.originalTodo = angular.extend({}, todo);
}; };
$scope.doneEditing = function (todo) { $scope.saveEdits = function (todo, event) {
$scope.editedTodo = null; // 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(); todo.title = todo.title.trim();
if (!todo.title) { if (todo.title === $scope.originalTodo.title) {
$scope.removeTodo(todo); 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; todos[todos.indexOf(todo)] = $scope.originalTodo;
$scope.doneEditing($scope.originalTodo); $scope.editedTodo = null;
$scope.originalTodo = null;
$scope.reverted = true;
}; };
$scope.removeTodo = function (todo) { $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.clearCompletedTodos = function () {
$scope.todos = todos = todos.filter(function (val) { store.clearCompleted();
return !val.completed;
});
}; };
$scope.markAll = function (completed) { $scope.markAll = function (completed) {
todos.forEach(function (todo) { todos.forEach(function (todo) {
todo.completed = !completed; if (todo.completed !== completed) {
$scope.toggleCompleted(todo, completed);
}
}); });
}; };
}); });
/*global angular */ /*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') angular.module('todomvc')
.factory('todoStorage', function () { .factory('todoStorage', function ($http, $injector) {
'use strict'; '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 () { 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) || '[]'); return JSON.parse(localStorage.getItem(STORAGE_ID) || '[]');
}, },
put: function (todos) { _saveToLocalStorage: function (todos) {
localStorage.setItem(STORAGE_ID, JSON.stringify(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;
}); });
...@@ -3,24 +3,28 @@ ...@@ -3,24 +3,28 @@
'use strict'; 'use strict';
describe('Todo Controller', function () { describe('Todo Controller', function () {
var ctrl, scope; var ctrl, scope, store;
var todoList;
var todoStorage = { // Load the module containing the app, only 'ng' is loaded by default.
storage: {},
get: function () {
return this.storage;
},
put: function (value) {
this.storage = value;
}
};
// Load the module containing the app, only 'ng' is loaded by default.
beforeEach(module('todomvc')); beforeEach(module('todomvc'));
beforeEach(inject(function ($controller, $rootScope) { beforeEach(inject(function ($controller, $rootScope, localStorage) {
scope = $rootScope.$new(); 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 () { it('should not have an edited Todo on start', function () {
...@@ -48,6 +52,7 @@ ...@@ -48,6 +52,7 @@
it('should filter non-completed', inject(function ($controller) { it('should filter non-completed', inject(function ($controller) {
ctrl = $controller('TodoCtrl', { ctrl = $controller('TodoCtrl', {
$scope: scope, $scope: scope,
store: store,
$routeParams: { $routeParams: {
status: 'active' status: 'active'
} }
...@@ -64,7 +69,8 @@ ...@@ -64,7 +69,8 @@
$scope: scope, $scope: scope,
$routeParams: { $routeParams: {
status: 'completed' status: 'completed'
} },
store: store
}); });
scope.$emit('$routeChangeSuccess'); scope.$emit('$routeChangeSuccess');
...@@ -77,10 +83,9 @@ ...@@ -77,10 +83,9 @@
var ctrl; var ctrl;
beforeEach(inject(function ($controller) { beforeEach(inject(function ($controller) {
todoStorage.storage = [];
ctrl = $controller('TodoCtrl', { ctrl = $controller('TodoCtrl', {
$scope: scope, $scope: scope,
todoStorage: todoStorage store: store
}); });
scope.$digest(); scope.$digest();
})); }));
...@@ -113,28 +118,16 @@ ...@@ -113,28 +118,16 @@
var ctrl; var ctrl;
beforeEach(inject(function ($controller) { 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', { ctrl = $controller('TodoCtrl', {
$scope: scope, $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(); scope.$digest();
})); }));
...@@ -146,22 +139,22 @@ ...@@ -146,22 +139,22 @@
}); });
it('should save Todos to local storage', function () { 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 () { it('should remove Todos w/o title on saving', function () {
var todo = todoList[2]; var todo = store.todos[2];
scope.editTodo(todo);
todo.title = ''; todo.title = '';
scope.saveEdits(todo);
scope.doneEditing(todo);
expect(scope.todos.length).toBe(4); expect(scope.todos.length).toBe(4);
}); });
it('should trim Todos on saving', function () { it('should trim Todos on saving', function () {
var todo = todoList[0]; var todo = store.todos[0];
scope.editTodo(todo);
todo.title = ' buy moar unicorns '; todo.title = ' buy moar unicorns ';
scope.saveEdits(todo);
scope.doneEditing(todo);
expect(scope.todos[0].title).toBe('buy moar unicorns'); expect(scope.todos[0].title).toBe('buy moar unicorns');
}); });
...@@ -171,16 +164,16 @@ ...@@ -171,16 +164,16 @@
}); });
it('markAll() should mark all Todos completed', function () { it('markAll() should mark all Todos completed', function () {
scope.markAll(); scope.markAll(true);
scope.$digest(); scope.$digest();
expect(scope.completedCount).toBe(5); expect(scope.completedCount).toBe(5);
}); });
it('revertTodo() get a Todo to its previous state', function () { it('revertTodo() get a Todo to its previous state', function () {
var todo = todoList[0]; var todo = store.todos[0];
scope.editTodo(todo); scope.editTodo(todo);
todo.title = 'Unicorn sparkly skypuffles.'; todo.title = 'Unicorn sparkly skypuffles.';
scope.revertEditing(todo); scope.revertEdits(todo);
scope.$digest(); scope.$digest();
expect(scope.todos[0].title).toBe('Uncompleted Item 0'); expect(scope.todos[0].title).toBe('Uncompleted Item 0');
}); });
......
...@@ -2265,6 +2265,6 @@ ...@@ -2265,6 +2265,6 @@
}] }]
}, },
"templates": { "templates": {
"todomvc": "<header> <h3><%= name %></h3> <span class=\"source-links\"> <% if (typeof examples !== 'undefined') { %> <% examples.forEach(function (example) { %> <h5><%= example.name %></h5> <% if (!location.href.match(example.url + '/')) { %> <a class=\"demo-link\" href=\"<%= example.url %>\">Demo</a>, <% } %> <a href=\"https://github.com/tastejs/todomvc/tree/gh-pages/<%= example.source_url ? example.source_url : example.url %>\">Source</a> <% }); %> <% } %> </span> </header> <hr> <blockquote class=\"quote speech-bubble\"> <p><%= description %></p> <footer> <a href=\"http://<%= homepage %>\"><%= name %></a> </footer> </blockquote> <% if (typeof link_groups !== 'undefined') { %> <hr> <% link_groups.forEach(function (link_group) { %> <h4><%= link_group.heading %></h4> <ul> <% link_group.links.forEach(function (link) { %> <li> <a href=\"<%= link.url %>\"><%= link.name %></a> </li> <% }); %> </ul> <% }); %> <% } %> <footer> <hr> <em>If you have other helpful links to share, or find any of the links above no longer work, please <a href=\"https://github.com/tastejs/todomvc/issues\">let us know</a>.</em> </footer>" "todomvc": "<header> <h3><%= name %></h3> <span class=\"source-links\"> <% if (typeof examples !== 'undefined') { %> <% examples.forEach(function (example) { %> <h5><%= example.name %></h5> <% if (!location.href.match(example.url + '/')) { %> <a class=\"demo-link\" data-type=\"<%= example.type === 'backend' ? 'external' : 'local' %>\" href=\"<%= example.url %>\">Demo</a>, <% } if (example.type === 'backend') { %><a href=\"<%= example.source_url %>\"><% } else { %><a href=\"https://github.com/tastejs/todomvc/tree/gh-pages/<%= example.source_url ? example.source_url : example.url %>\"><% } %>Source</a> <% }); %> <% } %> </span> </header> <hr> <blockquote class=\"quote speech-bubble\"> <p><%= description %></p> <footer> <a href=\"http://<%= homepage %>\"><%= name %></a> </footer> </blockquote> <% if (typeof link_groups !== 'undefined') { %> <hr> <% link_groups.forEach(function (link_group) { %> <h4><%= link_group.heading %></h4> <ul> <% link_group.links.forEach(function (link) { %> <li> <a href=\"<%= link.url %>\"><%= link.name %></a> </li> <% }); %> </ul> <% }); %> <% } %> <footer> <hr> <em>If you have other helpful links to share, or find any of the links above no longer work, please <a href=\"https://github.com/tastejs/todomvc/issues\">let us know</a>.</em> </footer>"
} }
} }
{ {
"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": { "devDependencies": {
"del": "^0.1.1", "del": "^0.1.1",
"gulp": "^3.8.5", "gulp": "^3.8.5",
...@@ -19,9 +34,5 @@ ...@@ -19,9 +34,5 @@
"jshint-stylish": "^1.0.0", "jshint-stylish": "^1.0.0",
"psi": "^0.1.1", "psi": "^0.1.1",
"run-sequence": "^1.0.1" "run-sequence": "^1.0.1"
}, }
"engines": {
"node": ">=0.10.0"
},
"private": true
} }
'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;
}
});
}
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment