Now that you have a Flatiron "hello world" example running, you need to extend it so that our to-do application takes shape. For this you will need to create and change some files. If you ever get lost, you can always refer to the chapter's source code. Also, for your reference, there is a complete list of the project files included at the end of this chapter.
As in any real application, you will need a reliable way to persist data. Here we will use CouchDB, the open-source and document-oriented database. You can either choose to install CouchDB locally or use a service over the Internet, such as Iris Couch.
If you choose to install CouchDB on your local development machine, you can head out and visit http://couchdb.apache.org/, click on Download and follow the instructions.
If you prefer to simply use CouchDB over the Internet, you can head out to http://www.iriscouch.com/, click on the Sign Up Now button and fill the registration form. You should have a running CouchDB instance in a matter of seconds.
To access a CouchDB database from Node we will use a library called nano
, which you will add to the dependencies section of your package.json
file:
{
"description": "To-do App",
"version": "0.0.0",
"private": true,
"dependencies": {
"union": "0.3.0",
"flatiron": "0.2.8",
"plates": "0.4.6",
"node-static": "0.6.0",
"nano": "3.3.0"
},
"scripts": {
"test": "vows --spec",
"start": "node app.js"
},
"name": "todo",
"author": "Pedro",
"homepage": ""
}
Now you can install this missing dependency by running the following command at the root of your application:
$ npm install nano@3.3.0 node_modules/nano ├── errs@0.2.3 ├── request@2.9.203.8.0 (request@2.2.9request@2.2.9)
This installs nano
inside the node_modules
folder, making it available for help while building this app.
To actually connect to the database, you need to define the CouchDB server URL. If you're running CouchDB locally, the URL should be similar to ht
tp://127.0.0.1:5984
. If you are running CouchDB in Iris Couch or a similar service, your URL will be similar to https://mytodoappcouchdb.iriscouch.com
.
In any of these cases, if you need to access using a username and a password, you should encode these in the URL, http://username:password@mytodoappco
uchdb.iriscouch.com
This URL should now be entered into a configuration file under config/config.json
, under the couchdb
key:
{ "couchdb": "http://localhost:5984" }
Next, encapsulate the access to the database by providing a simple module under lib/couchdb.js
:
var nano = require('nano'), config = require('../config/config.json'); module.exports = nano(config.couchdb);
This module will be used to get a CouchDB server object instead of repeating the config
and nano
dance several times throughout the code.
As many websites do nowadays, we will be using the Twitter Bootstrap framework to help us in getting the website look and feel minimal yet presentable. For that you will head out to the Bootstrap website http://twitter.github.com/bootstrap/ and click on the Download Bootstrap button:
You will get a zip file, which you should expand into the local public
folder, ending up with these files:
$ tree public/ public/ ├── css │ ├── bootstrap-responsive.css │ ├── bootstrap-responsive.min.css │ ├── bootstrap.css │ └── bootstrap.min.css ├── img │ ├── glyphicons-halflings-white.png │ └── glyphicons-halflings.png └── js ├── bootstrap.js └── bootstrap.min.js
You will also need to add jQuery into the mix since Bootstrap depends on it. Download jQuery from http://jquery.com and name it public/js/jquery.min.js
.
Now that we have Bootstrap and jQuery installed, it's time to create the frontend of our application.
First we will set up the layout HTML template, which defines the outer structure for all the pages. For hosting all the templates, we will have a directory named templates
, containing the following under templates/layout.html
:
<html> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <title id="title"></title> <link href="/css/bootstrap.min.css" rel="stylesheet" /> </head> <body> <section role="main" class="container"> <div id="messages"></div> <div id="main-body"></div> </section> <script src="/js/jquery.min.js"></script> <script src="/js/bootstrap.min.js"></script> </body> </html>
This template loads the CSS and scripts and contains the placeholders for the messages and main section.
We also need a small module that gets the main content and some other options and applies them to this template. We'll place it inside templates/layout.js
:
var Plates = require('plates'), fs = require('fs'); var templates = { layout : fs.readFileSync(__dirname + '/layout.html', 'utf8'), alert : fs.readFileSync(__dirname + '/alert.html', 'utf8') }; module.exports = function(main, title, options) { if (! options) { options = {}; } var data = { "main-body": main, "title": title, 'messages': '' }; ['error', 'info'].forEach(function(messageType) { if (options[messageType]) { data.messages += Plates.bind(templates.alert, {message: options[messageType]}); } }); return Plates.bind(templates.layout, data); };
In Node.js, a module is simply a JavaScript file that is intended to be used by other modules. All the variables inside a module are private; if the module author wishes to expose a value or a function to the outside world, it modifies or sets the special variable in module.exports
.
In our case, this module exports a function that gets the markup for the main page content, the page title, and some options such as the info or the error message and applies it to the layout template.
We also need to place the following markup file under templates/alert.html
:
<div class="alert"> <a class="close" data-dismiss="alert">×</a> <p class="message"></p> </div>
Now we're ready to start implementing some of the requirements.
This app will be offering users a personal to-do list. Before they can access it, they need to be signed up in the system. For that you need to define some URLs that the user will use to fetch our user sign-up form and submit it.
Now you will be changing the app.js
file. This file contains a set of initialization procedures, including this block:
app.router.get('/', function () { this.res.json({ 'hello': 'world' }) });
This block is routing all the HTTP requests having a /
URL and where the HTTP method is GET
to the given function. This function will then be invoked for every request with these two characteristics in which case you are replying, {"hello":"world"}
, which the user will see printed on the browser.
Now we need to remove this routing and add some routes that allow a user to register himself.
For that, create a folder named routes
where you will place all the routing modules. The first one is routes/users.js
and will contain the following code:
var fs = require('fs'), couchdb = require('../lib/couchdb'), dbName = 'users', db = couchdb.use(dbName), Plates = require('plates'), layout = require('../templates/layout'); var templates = { 'new' : fs.readFileSync(__dirname + '/../templates/users/new.html', 'utf8'), 'show': fs.readFileSync(__dirname + '/../templates/users/show.html', 'utf8') }; function insert(doc, key, callback) { var tried = 0, lastError; (function doInsert() { tried ++; if (tried >= 2) { return callback(lastError); } db.insert(doc, key, function(err) { if (err) { lastError = err; if (err.status_code === 404) { couchdb.db.create(dbName, function(err) { if (err) { return callback(err); } doInsert(); }); } else { return callback(err); } } callback.apply({}, arguments); }); }()); } function render(user) { var map = Plates.Map(); map.where('id').is('email').use('email').as('value'); map.where('id').is('password').use('password').as('value'); return Plates.bind(templates['new'], user || {}, map); } module.exports = function() { this.get('/new', function() { this.res.writeHead(200, {'Content-Type': 'text/html'}); this.res.end(layout(render(), 'New User')); }); this.post('/', function() { var res = this.res, user = this.req.body; if (! user.email || ! user.password) { return this.res.end(layout(templates['new'], 'New User', {error: 'Incomplete User Data'})); } insert(user, this.req.body.email, function(err) { if (err) { if (err.status_code === 409) { return res.end(layout(render(user), 'New User', { error: 'We already have a user with that email address.'})); } console.error(err.trace); res.writeHead(500, {'Content-Type': 'text/html'}); return res.end(err.message); } res.writeHead(200, {'Content-Type': 'text/html'}); res.end(layout(templates['show'], 'Registration Complete')); }); }); };
This new module exports a function that will bind two new routes GET /new
and POST /
. These routes will later be appended to the /users
namespace, which means that they will get activated when the server receives a GET
request to /users/new
and a POST
request to /users
.
On the GET /new
route, we will present a template that contains a user form. Place it under templates/users/new.html
:
<h1>New User</h1> <form action="/users" method="POST"> <p> <label for="email">E-mail</label> <input type="email" name="email" value="" id="email" /> </p> <p> <label for="password">Password</label> <input type="password" name="password" id="password" value="" required/> </p> <input type="submit" value="Submit" /> </form>
We will also need to create a Thank you for registering
template, which you need to place in templates/users/show.html
:
<h1>Thank you!</h1> <p>Thank you for registering. You can now <a href="/session/new">log in here</a></p>
In the POST /
route handler, we'll do some simple validation and insert the user document into the CouchDB database by calling the function named insert
. This function tries to insert the user document and makes use of some clever error handling. If the error is a "404 Not Found", it means that the users
database hasn't been created, and we take the opportunity to create it and automatically repeat the user document insertion.
You're also catching the 409 Conflict HTTP status code, which CouchDB will return if we try to insert a document with a key that already exists. Since we're using the user e-mail as the document key, we inform the user that such a username already exists.
Now we need to attach these new routes to the /users/
URL namespace by updating and adding a line right before app.start(3000)
in the file app.js
:
var flatiron = require('flatiron'),
path = require('path'),
nstatic = require('node-static'),
app = flatiron.app;
app.config.file({ file: path.join(__dirname, 'config', 'config.json') });
var file = new nstatic.Server(__dirname + '/public/');
app.use(flatiron.plugins.http, {
before: [
function(req, res) {
var found = app.router.dispatch(req, res);
if (! found) {
file.serve(req, res);
}
}
]
});
app.router.path('/users', require('./routes/users'));
app.start(3000);
Now you can start your application by typing in the command line:
$ node app
This starts the server. Then open a web browser and hit http://localhost:3000/users/new
. You will get a user form:
Submit an e-mail and a password and you will get a confirmation screen:
This screen will present you with a link to the /session/new
URL, which doesn't exist yet.
Now you're ready to implement the login screens.
To be able to keep a session, your HTTP server needs to be able to do two things: parse cookies and store session data. For this we use two modules, namely, flatware-cookie-parser
and flatware-session
, which you should add to the package.json
manifest:
{ "description": "To-do App", "version": "0.0.0", "private": true, "dependencies": { "union": "0.3.0", "flatiron": "0.2.8", "plates": "0.4.x", "node-static": "0.6.0", "nano": "3.3.0", "flatware-cookie-parser": "0.1.x", "flatware-session": "0.1.x" }, "scripts": { "test": "vows --spec", "start": "node app.js" }, "name": "todo", "author": "Pedro", "homepage": "" }
Now, install the missing dependencies:
$ npm install flatware-cookie-parser@0.1.0 node_modules/flatware-cookie-parser flatware-session@0.1.0 node_modules/flatware-session
Next, add these middleware components to your server in the file app.js
:
var flatiron = require('flatiron'), path = require('path'), nstatic = require('node-static'), app = flatiron.app; app.config.file({ file: path.join(__dirname, 'config', 'config.json') }); var file = new nstatic.Server(__dirname + '/public/'); app.use(flatiron.plugins.http, { before: [ require('flatware-cookie-parser')(), require('flatware-session')(), function(req, res) { var found = app.router.dispatch(req, res); if (! found) { file.serve(req, res); } } ] }); app.router.path('/users', require('./routes/users')); app.router.path('/session', require('./routes/session')); app.start(3000);
We also need to create a routes/session.js
module to handle the new session routes:
var plates = require('plates'), fs = require('fs'), couchdb = require('../lib/couchdb'), dbName = 'users', db = couchdb.use(dbName), Plates = require('plates'), layout = require('../templates/layout'); var templates = { 'new' : fs.readFileSync(__dirname + '/../templates/session/new.html', 'utf8') }; module.exports = function() { this.get('/new', function() { this.res.writeHead(200, {'Content-Type': 'text/html'}); this.res.end(layout(templates['new'], 'Log In')); }); this.post('/', function() { var res = this.res, req = this.req, login = this.req.body; if (! login.email || ! login.password) { return res.end(layout(templates['new'], 'Log In', {error: 'Incomplete Login Data'})); } db.get(login.email, function(err, user) { if (err) { if (err.status_code === 404) { // User was not found return res.end(layout(templates['new'], 'Log In', {error: 'No such user'})); } console.error(err.trace); res.writeHead(500, {'Content-Type': 'text/html'}); return res.end(err.message); } if (user.password !== login.password) { res.writeHead(403, {'Content-Type': 'text/html'}); return res.end(layout(templates['new'], 'Log In', {error: 'Invalid password'})); } // store session req.session.user = user; // redirect user to TODO list res.writeHead(302, {Location: '/todos'}); res.end(); }); }); };
Next we need to add a view template under templates/session/new.html
that contains the login form:
<h1>Log in</h1> <form action="/session" method="POST"> <p> <label for="email">E-mail</label> <input type="email" name="email" value="" id="email"/> </p> <p> <label for="password">Password</label> <input type="password" name="password" id="password" value="" required/> </p> <input type="submit" value="Log In" /> </form>
Next, stop the server if it's still running (by pressing Ctrl + C) and start it again:
$ node app.js
Point your browser to http://localhost:3000/session/new
and insert the e-mail and password of a user you already have registered:
If the loginsucceeds, you will be redirected to the /todos
URL, which the server does not respond to yet.
Next we're going to make the to-do list work.
For displaying the to-do list, we're going to use a table. It would be nice to sort the to-do items by using drag-and-drop. An easy way to enable this is by using jQuery UI. For this feature alone you don't need the full jQuery UI library, you can download a custom-built one by pointing your browser to http://jqueryui.com/download
, deselecting every option except the Sortable option in the Interactions element, and clicking on the Download button. Unzip the resulting file and copy the jquery-ui-1.8.23.custom.min.js
file into public/js
.
We need to refer this script in the templates.html
or layout.html
file:
<html> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <title id="title"></title> <link href="/css/bootstrap.min.css" rel="stylesheet" /> </head> <body> <section role="main" class="container"> <div id="messages"></div> <div id="main-body"></div> </section> <script src="/js/jquery.min.js"></script> <script src="/js/jquery-ui-1.8.23.custom.min.js"></script> <script src="/js/bootstrap.min.js"></script> <script src="/js/todos.js"></script> </body> </html>
You should also add a file under public/js/todos.js
that will contain some frontend interactive code.
Now we need to respond to the /todos
URL by firstly including the new routing in the app.js
file:
var flatiron = require('flatiron'),
path = require('path'),
nstatic = require('node-static'),
app = flatiron.app;
app.config.file({ file: path.join(__dirname, 'config', 'config.json') });
var file = new nstatic.Server(__dirname + '/public/');
app.use(flatiron.plugins.http, {
before: [
require('flatware-cookie-parser')(),
require('flatware-session')(),
function(req, res) {
var found = app.router.dispatch(req, res);
if (! found) {
file.serve(req, res);
}
}
]
});
app.router.path('/users', require('./routes/users'));
app.router.path('/session', require('./routes/session'));
app.router.path('/todos', require('./routes/todos'));
app.start(3000);
Then we need to place the new to-do routes module under routes/todos.js
:
var fs = require('fs'), couchdb = require('../lib/couchdb'), dbName = 'todos', db = couchdb.use(dbName), Plates = require('plates'), layout = require('../templates/layout'), loggedIn = require('../middleware/logged_in')(); var templates = { index : fs.readFileSync(__dirname + '/../templates/todos/index.html', 'utf8'), 'new' : fs.readFileSync(__dirname + '/../templates/todos/new.html', 'utf8') }; function insert(email, todo, callback) { var tries = 0, lastError; (function doInsert() { tries ++; if (tries >= 3) return callback(lastError); db.get(email, function(err, todos) { if (err && err.status_code !== 404) return callback(err); if (! todos) todos = {todos: []}; todos.todos.unshift(todo); db.insert(todos, email, function(err) { if (err) { if (err.status_code === 404) { lastError = err; // database does not exist, need to create it couchdb.db.create(dbName, function(err) { if (err) { return callback(err); } doInsert(); }); return; } return callback(err); } return callback(); }); }); })(); } module.exports = function() { this.get('/', [loggedIn, function() { var res = this.res; db.get(this.req.session.user.email, function(err, todos) { if (err && err.status_code !== 404) { res.writeHead(500); return res.end(err.stack); } if (! todos) todos = {todos: []}; todos = todos.todos; todos.forEach(function(todo, idx) { if (todo) todo.pos = idx + 1; }); var map = Plates.Map(); map.className('todo').to('todo'); map.className('pos').to('pos'); map.className('what').to('what'); map.where('name').is('pos').use('pos').as('value'); var main = Plates.bind(templates.index, {todo: todos}, map); res.writeHead(200, {'Content-Type': 'text/html'}); res.end(layout(main, 'To-Dos')); }); }]); this.get('/new', [loggedIn, function() { this.res.writeHead(200, {'Content-Type': 'text/html'}); this.res.end(layout(templates['new'], 'New To-Do')); }]); this.post('/', [loggedIn, function() { var req = this.req, res = this.res, todo = this.req.body ; if (! todo.what) { res.writeHead(200, {'Content-Type': 'text/html'}); return res.end(layout(templates['new'], 'New To-Do', {error: 'Please fill in the To-Do description'})); } todo.created_at = Date.now(); insert(req.session.user.email, todo, function(err) { if (err) { res.writeHead(500); return res.end(err.stack); } res.writeHead(303, {Location: '/todos'}); res.end(); }); }]); this.post('/sort', [loggedIn, function() { var res = this.res, order = this.req.body.order && this.req.body.order.split(','), newOrder = [] ; db.get(this.req.session.user.email, function(err, todosDoc) { if (err) { res.writeHead(500); return res.end(err.stack); } var todos = todosDoc.todos; if (order.length !== todos.length) { res.writeHead(409); return res.end('Conflict'); } order.forEach(function(order) { newOrder.push(todos[parseInt(order, 10) - 1]); }); todosDoc.todos = newOrder; db.insert(todosDoc, function(err) { if (err) { res.writeHead(500); return res.end(err.stack); } res.writeHead(200); res.end(); }); }); }]); this.post('/delete', [loggedIn, function() { var req = this.req, res = this.res, pos = parseInt(req.body.pos, 10) ; db.get(this.req.session.user.email, function(err, todosDoc) { if (err) { res.writeHead(500); return res.end(err.stack); } var todos = todosDoc.todos; todosDoc.todos = todos.slice(0, pos - 1).concat(todos.slice(pos)); db.insert(todosDoc, function(err) { if (err) { res.writeHead(500); return res.end(err.stack); } res.writeHead(303, {Location: '/todos'}); res.end(); }); }); }]); };
This module responds to the to-do index (GET /todos
), fetching and presenting all the to-do items for the logged-in user. Place the following template under templates/todos/index.html
:
<h1>Your To-Dos</h1> <a class="btn" href="/todos/new">New To-Do</a> <table class="table"> <thead> <tr> <th>#</th> <th>What</th> <th></th> </tr> </thead> <tbody id="todo-list"> <tr class="todo"> <td class="pos"></td> <td class="what"></td> <td class="remove"> <form action="/todos/delete" method="POST"> <input type="hidden" name="pos" value="" /> <input type="submit" name="Delete" value="Delete" /> </form> </td> </tr> </tbody> </table>
Another new route is GET /todos/new
, presenting the user a form for creating a new to-do item. This route makes use of a new template placed in templates/todos/new.html
:
<h1>New To-Do</h1> <form action="/todos" method="POST"> <p> <label for="email">What</label> <textarea name="what" id="what" required></textarea> </p> <input type="submit" value="Create" /> </form>
The POST /todos
route creates a new to-do item by calling the local insert
function, which handles the error for when the database does not exist, creating it as needed and retrying the insert
function later.
The index template depends on the existence of a client-side script placed under public/js/todos.js
:
$(function() { $('#todo-list').sortable({ update: function() { var order = []; $('.todo').each(function(idx, row) { order.push($(row).find('.pos').text()); }); $.post('/todos/sort', {order: order.join(',')}, function() { $('.todo').each(function(idx, row) { $(row).find('.pos').text(idx + 1); }); }); } }); });
This file activates and handles the drag-and-drop item, making an AJAX call to the /todos/sort
URL with the new order of the to-do items.
The Delete button on each item is also handled in the todos.js
routing module by loading the user to-do items, removing the item at the given position and storing the items back.
To make this work, we need to provide a routing middleware under middleware/logged_in.js
. This middleware component is responsible for protecting some routes and, when the user is not logged in, redirecting the user to the login screen instead of executing that route:
function LoggedIn() { return function(next) { if (! this.req.session || ! this.req.session.user) { this.res.writeHead(303, {Location: '/session/new'}); return this.res.end(); } next(); }; } module.exports = LoggedIn;
Finally, stop the server if it's still running (by hitting Ctrl + C) and start it up again:
$ node app.js
Point your browser to http://localhost:3000/session/new
, and enter the e-mail and password of the user you already have registered. You will then be redirected to the to-do list of the user, which will start off empty.
You can now click on the New To-Do button, obtaining the following form:
Insert some text and click on the Create button. The to-do item will be inserted in the database and the updated to-do list will be presented:
You can insert as many to-do items as you like. Once you've had enough, you can try to reorder them by dragging-and-dropping the table rows.
You can also click on the Delete button to remove a specific to-do item.