Let's quickly review creating a new project utilizing todos.
-
Init a new Project
mkdir todo_tags_play && cd todo_tags_play git init
-
Add a quick
README
and first committouch README.md && git add . && git commit -m 'Add README'
-
Initialize the
npm
projecttouch server.js npm init -y echo 'node_modules' > .gitignore git add . && git commit -m 'Init an npm project'
-
Install
knex
andpg
npm install --save knex pg touch knexfile.js && db.js
Then setup your
knexfile
config for your development.knexfile.js
module.exports = { development: { client: 'pg', connection: 'postgres://localhost:5432/todo_tags_play' } }
Then setup your canonical
db.js
connection file.db.js
const dbConfig = require('./knexfile').development; const knex = require('knex')(dbConfig); module.exports = knex;
Then commit your work thus far.
git add . && git commit -m 'Add dev db connection and config'
-
Then actually create your database
createdb todo_tags_play
Begin setting up your table migrations for your todos
node_modules/.bin/knex migrate:make 'create_todo_table'
Then edit your migration to define your todos
module.exports = {
up(knex, Promise) {
return knex.schema.createTable('todos', (table) => {
table.increments();
table.string('title', 255).notNull();
table.text('description').defaultTo('');
table.timestamps(true, true);
});
},
down(knex, Promise) {
return knex.schema.dropTable('todos');
}
}
Then migrate your table
node_modules/.bin/knex migrate:latest
If all looks well in your database commit your work.
git add . && git commit -m 'Add todos migration'
- What are the following postgres tools for
createdb
anddropdb
? - What is the intended purpose of
migrate:latest
andmigrate:rollback
?
Now we'll move onto setup of your express server application for todos:
npm install --save express body-parser morgan ejs nodemon
git add . && git commit -m 'Add express app packages'
Then setup your canonical server.js
server.js
const express = require('express');
const morgan = require('morgan');
const bodyParser = require('body-parser');
const app = express();
const PORT = 8000;
app.set('view engine', 'ejs');
// Setup Middleware
app.use(morgan('combined'));
app.use(bodyParser.urlencoded({extended: true}));
app.use(bodyParser.json());
app.listen(PORT, () => {
console.log('Server listening on ', PORT);
})
Then add your nodemon
starting script.
"start": "nodemon"
Then commit your work
git add . && git commit -m 'Update server w/ canonical middleware'
- What is the purpose of
bodyParser.urlencoded
in your own words? - What is the purpose of
bodyParser.json
in your own words? - What is
Content-type
HTTP request header? - What will having both
bodyParser.json
andbodyParser.urlencoded
allow us to achieve?
Let's quickly setup your routes for your todos. This time we'll do this by first setting up a router and then loading these into our server.
See THE DOCS ON express.Router
mkdir routes
touch routes/todos.js
Then you can begin defining your routes
routes/todos.js
const app = require('express').Router();
/*
RESTful todos
*/
app.get('/', (req, res) => {
res.send('ALL TODOS');
});
app.get('/new', (req, res) => {
});
app.get('/:id', (req, res) => {
});
app.post('/', (req, res) => {
});
app.patch('/:id', (req, res) => {
});
app.delete('/:id', (req, res) => {
});
module.exports = app;
Then you can load this into your server.js
.
server.js
const todos = require('./routes/todos');
...
app.use('/todos', todos);
You should now have the following in your server.js
server.js
const express = require('express');
const morgan = require('morgan');
const bodyParser = require('body-parser');
// load routes
const todos = require('./routes/todos'); // <--- ADDED
const app = express();
const PORT = 8000;
app.set('view engine', 'ejs');
// Setup Middleware
app.use(morgan('combined'));
app.use(bodyParser.urlencoded({extended: true}));
app.use(bodyParser.json());
// Add routes
app.use('/todos', todos); // <----- ADDED
app.listen(PORT, () => {
console.log('Server listening on ', PORT);
})
You should be able to verify this is working properly by starting your server and going to localhost:8000/todos.
You should see ALL TODOS
.
- List out the RESTful paths and methods for a
kittens
resource
Let's begin by sketching out a query to create a todo in our database utilizing knex.
// CREATE A todo
function createTodo({ body: { title, description } }) {
return knex("todos")
.returning("*")
.insert({ title, description });
}
This will help us generate a query like the following:
INSERT INTO todos
(title, description)
VALUES
('A Short title', 'A brief description')
We can add this to our routes/todos.js
.
const app = require('express').Router();
const knex = require('../db');
/*
RESTful todos
*/
app.get('/', (req, res) => {
res.send('ALL TODOS')
});
app.get('/new', (req, res) => {
});
app.get('/:id', (req, res) => {
});
/*
http --json \
POST 'http://localhost:8000/todos' \
title='A Short Title' description='A short description.'
*/
app.post('/', (req, res) => {
createTodo(req)
.then((rows) => {
res.json(rows[0]);
})
});
app.patch('/:id', (req, res) => {
});
app.delete('/:id', (req, res) => {
});
// CREATE A todo
function createTodo({ body: { title, description } }) {
return knex("todos")
.returning("*")
.insert({ title, description });
}
module.exports = app;
Let's make a few more functions to help us query our database.
-
Let's make a
findTodos
function to query all todos in the database.function findTodos() { return knex('todos'); }
-
Let's make a
findTodo
function that returns a specified todo.function findTodo({params: { id }}) { return knex('todos').where('id', id); }
-
Let's make a
updateTodo
function that patches a specified todo.function updateTodo({ params: { id }, body: { title, description }, }) { return knex('todos') .where('id', id) .returning('*') .update({title, description}); }
-
Let's make a
destroyTodo
function to remove a todo.function destroyTodo({params: { id }}) { return knex('todos') .where('id', id) .del(); }
We should now have a routes/todos.js
that looks like the following:
const app = require("express").Router();
const knex = require("../db");
/*
RESTful todos
*/
/*
http --json \
GET 'http://localhost:8000/todos'
*/
app.get("/", (req, res) => {
findTodos(req).then(todos => res.json(todos));
});
app.get("/new", (req, res) => {
});
/*
http --json \
GET 'http://localhost:8000/todos/1'
*/
app.get("/:id", (req, res) => {
findTodo(req).then(todos => res.json(todos[0]));
});
/*
http --json \
POST 'http://localhost:8000/todos' \
title='A Short Title' description='A short description.'
*/
app.post("/", (req, res) => {
createTodo(req).then(todos => res.json(todos[0]));
});
/*
http --json \
PATCH 'http://localhost:8000/todos/1' \
title='COOOL!' description='WOOT!'
*/
app.patch("/:id", (req, res) => {
updateTodo(req).then(todos => res.json(todos[0]));
});
/*
http --json \
DELETE 'http://localhost:8000/todos/1'
*/
app.delete("/:id", (req, res) => {
destroyTodo(req).then(() => res.sendStatus(204));
});
/********** HELPER FUNCTIONS ************/
// CREATE A todo
function createTodo({ body: { title, description } }) {
return knex("todos")
.returning("*")
.insert({ title, description });
}
// Find all
function findTodos() {
return knex('todos');
}
// Find one
function findTodo({params: { id }}) {
return knex('todos').where('id', id);
}
// Update
function updateTodo({
params: { id },
body: { title, description },
}) {
return knex('todos')
.where('id', id)
.returning('*')
.update({title, description});
}
// Destroy
function destroyTodo({params: { id }}) {
return knex('todos')
.where('id', id)
.del();
}
module.exports = app;
Now that your server is setup we can git add and commit.
git add . && git commit -m 'Add todos json serving'
Start your server and verify each of the httpie
requests works correctly.
Now that we can server data reliably and fetch it from the database. We can begin work on adding some minimal ejs
views.
Let's begin by adding simple site routing.
mkdir -p views/site
Then let's add home.ejs
for our /
route.
touch views/site/home.ejs
Then we can drop in some simple HTML.
views/site/home.ejs
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Home</title>
</head>
<body>
<h1>ToDone</h1>
<h2>The Ultimate Todo App</h2>
</body>
</html>
Then we can begin serving this by adding router for site pages.
touch routes/site.js
Then we just the following route.
routes/site.js
const app = require('express').Router();
app.get('/', (req, res) => res.render('site/home'));
module.exports = app;
Then we tell our application to utilize this router.
server.js
// load routes
const site = require('./routes/site'); // <--- ADDED
const todos = require('./routes/todos');
...
// Add routes
app.use('/', site); // <----- ADDED
app.use('/todos', todos);
Your server.js
should now look like this:
const express = require('express');
const morgan = require('morgan');
const bodyParser = require('body-parser');
// load routes
const site = require('./routes/site'); // <--- ADDED
const todos = require('./routes/todos');
const app = express();
const PORT = 8000;
app.set('view engine', 'ejs');
// Setup Middleware
app.use(morgan('combined'));
app.use(bodyParser.urlencoded({extended: true}));
app.use(bodyParser.json());
// Add routes
app.use('/', site); // <----- ADDED
app.use('/todos', todos);
app.listen(PORT, () => {
console.log('Server listening on ', PORT);
})
View your site homepage at localhost:8000/...
You should have a functioning homepage.
git add . && git commit -m 'Add homepage route'
- Add an
/about
page in yourroutes/site.js
- Add a
/contact
page in yourroutes/site.js
- Update your
views/site/home.ejs
adding hyperlinks to your/about
and/contact
.
Let's setup views for our todos.
mkdir views/todos && touch views/todos/index.ejs
Then add the following HTML
views/todos/index.ejs
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>All Todos</title>
</head>
<body>
<h1>All Todos</h1>
</body>
</html>
Then we can serve this in our /todos
route.
routes/todos.js
/*
http --json \
GET 'http://localhost:8000/todos'
*/
app.get("/", (req, res) => {
findTodos(req).then(todos => {
res.format({
'text/html': res.render('todos/index'),
'application/json': res.json(todos)
})
});
});
You should now be able to go into your browser and view the HTML.
However, we would like to be able to see all todos so we begin templating them into them ejs as follows:
views/todos/index.ejs
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>All Todos</title>
</head>
<body>
<h1>All Todos</h1>
<% todos.forEach(todo => { %>
<div class="todo-item">
<h3><%= todo.title %></h3>
<p><%= todo.description %></p>
</div>
<% }) %>
</body>
</html>
Note you should review your ejs
syntax if this still looks unfamiliar. Finally, we can add tell our route to render the page with the todos data.
routes/todos.ejs
/*
http --json \
GET 'http://localhost:8000/todos'
*/
app.get("/", (req, res) => {
findTodos(req).then(todos => {
res.format({
'text/html': () => res.render('todos/index', { todos }),
'application/json': () => res.json(todos)
})
});
});
If you're able to see your todos you're ready to commit your changes.
git add . && git commit -m 'Add view for todos index'
Let's add a todo form
touch views/todos/new.ejs
Then you can add your form inputs for creating a new todo:
views/todos/new.ejs
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>New Todo</title>
</head>
<body>
<h1>A New Todo</h1>
<form action="/todos" method="POST">
<div>
<input type="text" name="title"/>
</div>
<div>
<textarea name="description"></textarea>
</div>
<button type="submit">Save Todo</button>
</form>
</body>
</html>
Now update your router to serve the correct form for GET /todos/new
.
routes/todos.js
app.get("/new", (req, res) => {
res.render('todos/new');
});
Go to localhost:8000/todos/new
in your browser, fill out the form, and view the json result.
If it you're able to do that then commit before moving on.
git add . && git commit -m 'Add view for todos/new'
You can now add a show page for just one todo. This would be helpful if someone wanted to quickly create a link to a todo and share it with a friend.
touch views/todos/show.ejs
Then we can make a our own
views/todos/show.ejs
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Show Todo</title>
</head>
<body>
<div class="todo-item">
<h1><%= todo.title %></h1>
<p><%= todo.description %></p>
</div>
</body>
</html>
Then we can update our router to render the show page.
routes/todos.js
/*
http --json \
GET 'http://localhost:8000/todos/1'
*/
app.get("/:id", (req, res) => {
findTodo(req).then(todos => {
const todo = todos[0];
res.format({
'text/html': () => res.render('todos/show', { todo }),
'application/json': () => res.json(todo)
})
});
});
You should be able to view your view at http://localhost:8000/todo/1
.
If so then let's add and commit before going on.
git add . && git commit -m 'Add view for todos/show'
After someone creates a todo they may want HTML or JSON. However, we only currently serve JSON. Let's update this by Redirecting users to the newly created todos/show
page after the create a todo.
routes/todos.js
app.post("/", (req, res) => {
createTodo(req).then(todos =>{
const todo = todos[0];
res.format({
'text/html': () => res.redirect(`/todos/${todo.id}`),
'application/json': () => res.json(todo)
})
});
});
Try going to http://localhost:8000/todos/new
and submitting a new todo.
If you just see the one todo then everything should be ready for a commit.
git add . && git commit -m 'Add redirect after todos/create'
- Add hyperlinks to your
todos/:id
to review all todos at/todos
.
Currently we have the following files
routes/todos.js
const app = require("express").Router();
const knex = require("../db");
/*
RESTful todos
*/
/*
http --json \
GET 'http://localhost:8000/todos'
*/
app.get("/", (req, res) => {
findTodos(req).then(todos => {
res.format({
'text/html': () => res.render('todos/index', { todos }),
'application/json': () => res.json(todos)
})
});
});
app.get("/new", (req, res) => {
res.render('todos/new');
});
/*
http --json \
GET 'http://localhost:8000/todos/1'
*/
app.get("/:id", (req, res) => {
findTodo(req).then(todos => {
const todo = todos[0];
res.format({
'text/html': () => res.render('todos/show', { todo }),
'application/json': () => res.json(todo)
})
});
});
/*
http --json \
POST 'http://localhost:8000/todos' \
title='A Short Title' description='A short description.'
*/
app.post("/", (req, res) => {
createTodo(req).then(todos =>{
const todo = todos[0];
res.format({
'text/html': () => res.redirect(`/todos/${todo.id}`),
'application/json': () => res.json(todo)
})
});
});
/*
http --json \
PATCH 'http://localhost:8000/todos/1' \
title='COOOL!' description='WOOT!'
*/
app.patch("/:id", (req, res) => {
updateTodo(req).then(todos => res.json(todos[0]));
});
/*
http --json \
DELETE 'http://localhost:8000/todos/1'
*/
app.delete("/:id", (req, res) => {
destroyTodo(req).then(() => res.sendStatus(204));
});
// CREATE A todo
function createTodo({ body: { title, description } }) {
return knex("todos")
.returning("*")
.insert({ title, description });
}
// Find all
function findTodos() {
return knex('todos');
}
// Find one
function findTodo({params: { id }}) {
return knex('todos').where('id', id);
}
// Update
function updateTodo({
params: { id },
body: { title, description },
}) {
return knex('todos')
.where('id', id)
.returning('*')
.update({title, description});
}
// Destroy
function destroyTodo({params: { id }}) {
return knex('todos')
.where('id', id)
.del();
}
module.exports = app;
server.js
const express = require('express');
const morgan = require('morgan');
const bodyParser = require('body-parser');
// load routes
const site = require('./routes/site');
const todos = require('./routes/todos');
const app = express();
const PORT = 8000;
app.set('view engine', 'ejs');
// Setup Middleware
app.use(morgan('combined'));
app.use(bodyParser.urlencoded({extended: true}));
app.use(bodyParser.json());
// Add routes
app.use('/', site);
app.use('/todos', todos);
app.listen(PORT, () => {
console.log('Server listening on ', PORT);
})
Often times people don't just create a todo in the wild without associating it to some kind of list. People often want to separate items based on projects, teams, or priority.
Let's discuss what we want in a list.
First what's a good name for a list? Calling it a List
is a bad idea because everything can be called a List
.
- Synonyms: collection (bad), chronicles (okay), records (nope), notes (okay), agendas (okay), plans (good), schedules (misleading), ...
I like plans.
Plan |
---|
title:string |
archived:boolean |
created_at:timestamp |
updated_at:timestamp |
How do we want to relate this to a Todo
? Cleary, a plan
has more than one todo
. Question is now do we want a todo to be a many-to-many or one-to-many.
If we utilize a one-to-many then we are enforcing that one todo can only ever belong to one planning list. This seems right. However, let's consider if we might want the many-to-many.
- Utilizing an associative/join table allows us to easily share a card's content between multiple lists without having to re-create both the title and description of that todo.
- Utilizing an associative/join table allows us to store meta information about the plan/todo relationship without altering the todo itself. This might be helpful when start to add the ability to
complete
atodo
. - Utilizing a todo might help if we add
tags
later that we want to be plan specific for a todo.
Drawbacks...
We don't have any of that at the moment. YAGNI? You Aren't Going To Need It. YAGNI tells us that if our MVP is just a Plan with Todo items then we could probably go a long way with just the one to many relationship. HOWEVER, databases are all about planning and design, and you should probably lean more towards a many-to-many relationship in many cases.
For simplicity we will just design with a one-to-many
relationship. This means a Plan
will have many Todo
items and Todo
will need a foreign key.
node_modules/.bin/knex migrate:make 'create_plans_table'
migrations/*_create_plans_table.js
module.exports = {
up(knex, Promise) {
return knex.schema.createTable('plans', (table) => {
table.increments();
table.string('title', 255).notNull();
table.boolean('archived').notNull().defaultTo(false);
table.timestamps(true, true);
});
},
down(knex, Promise) {
return knex.schema.dropTable('plans');
}
}
Then we can migrate our plans table.
node_modules/.bin/knex migrate:latest
Be sure to check your migrations look correct. Next you might want rollback or reset your database before the next migration.
repeat 2 node_modules/.bin/knex migrate:rollback
This will just help us quickly remove todos that we've already created.
Next we add the foreign key to our todos
table.
node_modules/.bin/knex migrate:make 'add_plans_references_to_todo'
Next we add a migration to add the reference column
module.exports = {
up(knex, Promise) {
return knex.schema.table("todos", table => {
table
.integer("plan_id")
.references("plans.id")
.notNull()
.onDelete("cascade")
.index(); // Adds a db index for fast lookups
});
},
down(knex, Promise) {
return knex.schema.table("todos", table => table.dropColumn("plan_id"));
}
};
Then run your current set of migrations.
node_modules/.bin/knex migrate:latest
If everything looks fine in your database for todos then you are ready to commit.
git add . && git commit -m 'Add plans_table migrations'
Let's add a models folder to help store helpers for various tables so they don't bloat our routes tables.
mkdir models
touch models/todos.js
Then cut and paste our helpers from our routes/todos.js
models/todos.js
const knex = require("../db");
// CREATE A todo
function createTodo({ body: { title, description } }) {
return knex("todos")
.returning("*")
.insert({ title, description });
}
// Find all
function findTodos() {
return knex("todos");
}
// Find one
function findTodo({ params: { id } }) {
return knex("todos").where("id", id);
}
// Update
function updateTodo({ params: { id }, body: { title, description } }) {
return knex("todos")
.where("id", id)
.returning("*")
.update({ title, description });
}
// Destroy
function destroyTodo({ params: { id } }) {
return knex("todos")
.where("id", id)
.del();
}
module.exports = {
createTodo,
findTodos,
findTodo,
updateTodo,
destroyTodo
};
Then make sure your routes/todos.js
looks like the following:
routes/todos.js
/*
RESTful todos
*/
const app = require("express").Router();
const {
createTodo,
findTodos,
findTodo,
updateTodo,
destroyTodo
} = require("../models/todos");
/*
http --json \
GET 'http://localhost:8000/todos'
*/
app.get("/", (req, res) => {
findTodos(req).then(todos => {
res.format({
"text/html": () => res.render("todos/index", { todos }),
"application/json": () => res.json(todos)
});
});
});
app.get("/new", (req, res) => {
res.render("todos/new");
});
/*
http --json \
GET 'http://localhost:8000/todos/1'
*/
app.get("/:id", (req, res) => {
findTodo(req).then(todos => {
const todo = todos[0];
res.format({
"text/html": () => res.render("todos/show", { todo }),
"application/json": () => res.json(todo)
});
});
});
/*
http --json \
POST 'http://localhost:8000/todos' \
title='A Short Title' description='A short description.'
*/
app.post("/", (req, res) => {
createTodo(req).then(todos => {
const todo = todos[0];
res.format({
"text/html": () => res.redirect(`/todos/${todo.id}`),
"application/json": () => res.json(todo)
});
});
});
/*
http --json \
PATCH 'http://localhost:8000/todos/1' \
title='COOOL!' description='WOOT!'
*/
app.patch("/:id", (req, res) => {
updateTodo(req).then(todos => res.json(todos[0]));
});
/*
http --json \
DELETE 'http://localhost:8000/todos/1'
*/
app.delete("/:id", (req, res) => {
destroyTodo(req).then(() => res.sendStatus(204));
});
module.exports = app;
You can convince yourself this works by visiting http://localhost:8000/todos/new
or http://localhost:8000/todos
. You won't be able to create todos until we add routing for creating lists.
We want to be able to do CRUD for plans
much like we were able to do CRUD for todos.
touch routes/plans.js
Then sketch out your CRUD routes
routes/plans.js
/*
RESTful plans
*/
const app = require('express').Router();
app.get('/', (req, res) => {
res.send('ALL plans');
});
app.get('/new', (req, res) => {
});
app.get('/:id', (req, res) => {
});
app.post('/', (req, res) => {
});
app.patch('/:id', (req, res) => {
});
app.delete('/:id', (req, res) => {
});
module.exports = app;
Then create your model file for your plans db helpers
touch models/plans.js
Then sketch out your helper functions
models/plans.js
const knex = require("../db");
// CREATE
function createPlan({ body: { title } }) {
return knex("plans")
.returning("*")
.insert({ title, });
}
// Find all
function findPlans() {
return knex("plans");
}
// Find one
function findPlan({ params: { id } }) {
return knex("plans").where("id", id);
}
// Update
function updatePlan({ params: { id }, body: { title, archived } }) {
return knex("plans")
.where("id", id)
.returning("*")
.update({ title, archived });
}
// Destroy
function destroyPlan({ params: { id } }) {
return knex("plans")
.where("id", id)
.del();
}
module.exports = {
createPlan,
findPlans,
findPlan,
updatePlan,
destroyPlan
};
Now we can utilize these to route requests for our plans
. NOTE: we'll need to wait until the next section to start planning our HTML views for the plans
resource.
routes/plan.js
/*
RESTful plans
*/
const app = require("express").Router();
const {
createPlan,
findPlans,
findPlan,
updatePlan,
destroyPlan
} = require("../models/plans");
/*
http --json \
GET 'http://localhost:8000/plans'
*/
app.get("/", (req, res) => {
findPlans(req).then(plans => {
res.format({
"text/html": () => res.render("plans/index", { plans }),
"application/json": () => res.json(plans)
});
});
});
app.get("/new", (req, res) => {
res.render("plans/new");
});
/*
http --json \
GET 'http://localhost:8000/plans/1'
*/
app.get("/:id", (req, res) => {
findPlan(req).then(plans => {
const plan = plans[0];
res.format({
"text/html": () => res.render("plans/show", { plan }),
"application/json": () => res.json(plan)
});
});
});
/*
http --json \
POST 'http://localhost:8000/plans' \
title='A Short Title'
*/
app.post("/", (req, res) => {
createPlan(req).then(plans => {
const plan = plans[0];
res.format({
"text/html": () => res.redirect(`/plans/${plan.id}`),
"application/json": () => res.json(plan)
});
});
});
/*
http --json \
PATCH 'http://localhost:8000/plans/1' \
title='COOOL!' archived=true
*/
app.patch("/:id", (req, res) => {
updatePlan(req).then(plans => res.json(plans[0]));
});
/*
http --json \
DELETE 'http://localhost:8000/plans/1'
*/
app.delete("/:id", (req, res) => {
destroyPlan(req).then(() => res.sendStatus(204));
});
module.exports = app;
Then just be sure to add your new route to your server.js
server.js
const express = require('express');
const morgan = require('morgan');
const bodyParser = require('body-parser');
// load routes
const site = require('./routes/site');
const plans = require('./routes/plans'); // <--- ADDED
const todos = require('./routes/todos');
const app = express();
const PORT = 8000;
app.set('view engine', 'ejs');
// Setup Middleware
app.use(morgan('combined'));
app.use(bodyParser.urlencoded({extended: true}));
app.use(bodyParser.json());
// Add routes
app.use('/', site);
app.use('/plans', plans); // <--- Added
app.use('/todos', todos);
app.listen(PORT, () => {
console.log('Server listening on ', PORT);
})
If you're able to verify that the routes for the plans
resource works correctly then you're ready to commit and move on to the next section.
git add . && git commit -m 'Add plans CRUD resource'
Next we can work on creating the views much like we did for our todos.
mkdir 'views/plans'
touch views/plans/{index,show,new}.ejs
Let's begin with a view for a plans/index.ejs
views/plans/index.ejs
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Plans</title>
</head>
<body>
<h1>All Plans</h1>
<% plans.forEach((plan) => { %>
<div>
<a href="/plans/<%= plan.id %>"><h3><%= plan.title %></h3></a>
</div>
<% }) %>
</body>
</html>
Here we utilized a hyperlink tag to link from the plan index page to the plan show page.
<a href="/plans/<%= plan.id %>"><h3><%= plan.title %></h3></a>
If the plan had id 12
and title Home
then the rendered HTML would have the following
<a href="/plans/12"><h3>Home</h3></a>
Next we'll make a simple new
page.
views/plans/new.ejs
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>New Plan</title>
</head>
<body>
<h1>New Plan</h1>
<form action="/plans" method="post">
<div>
<input type="text" name="title"/>
</div>
<button type="submit">Create Plan</button>
</form>
</body>
</html>
Next we'll need a show page for a particular plan.
views/plans/show.ejs
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Plan Show</title>
</head>
<body>
<h1>Show Plan</h1>
<div>
<h2><%= plan.title %></h2>
</div>
</body>
</html>
Now you should be ready to commit your work.
git add . && git commit -m 'Add views for plans'
Now that we've made it this far with our plan
and todo
models it's time we start to link them. We can no longer create or update todos without worry about which plan it belongs to.
We'll just make the simplest switch to our server.js
first.
app.use('/plans/:plan_id/todos', todos);
Here we are now mounting all todo routes under the /plans/:plan_id/todos
.
This effectively sets up the following routes
METHOD | PATH | DESCRIPTION |
---|---|---|
GET |
/plans/:plan_id/todos/ |
Should return all todos associated to the plan_id . |
GET |
/plans/:plan_id/todos/new |
Returns a form for creating todos associated to the plan_id . |
GET |
/plans/:plan_id/todos/:id |
Should return the todo associated to the plan_id . |
POST |
/plans/:plan_id/todos/ |
Creates a todo for the associated plan_id . |
PATCH |
/plans/:plan_id/todos/:id |
Updates the todo associated to the plan_id . |
DELETE |
/plans/:plan_id/todos/:id |
Destroys the todo associated to the plan_id . |
We'll also need to update our router
to merge in the plan_id
from the parent router.
routes/todos.js
/*
RESTful todos
*/
const app = require("express").Router({ mergeParams: true });
Let's update your new
form to include the plan_id
in the form view.
views/todos/new.ejs
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>New Todo</title>
</head>
<body>
<h1>A New Todo</h1>
<form action="/plans/<%= plan_id %>/todos" method="POST">
<div>
<input type="text" name="title"/>
</div>
<div>
<textarea name="description"></textarea>
</div>
<button type="submit">Save Todo</button>
</form>
</body>
</html>
This means we'll need to update our route to render with the plan_id
in the action.
routes/todos.js
app.get("/new", (req, res) => {
// We enforce that it should be a number
const plan_id = parseInt(req.params.plan_id, 10);
res.render("todos/new", { plan_id });
});
We'll also need to utilize our plan_id
in our creation method
models/todos.js
// CREATE A todo
function createTodo({params: { plan_id }, body: { title, description } }) {
return knex("todos")
.returning("*")
.insert({ title, description, plan_id });
}
Then we want to update our route to redirect to the correct view route.
routes/todos.js
app.post("/", (req, res) => {
createTodo(req).then(todos => {
const todo = todos[0];
const { plan_id } = req.params;
res.format({
"text/html": () => res.redirect(`/plans/${plan_id}/todos/${todo.id}`),
"application/json": () => res.json(todo)
});
});
});
Let's try this is out for ourselves:
- Go to
http://localhost:8000/plans/new
and create a new plan calledTest
. - Get the plan id from the url and go to
/plans/:plan_id/todos/new
. - Then fill out the form for a new todo.
- You should be redirected to the correct todo.
You can make this easier by updating your view for your plans
show page.
views/plans/show.ejs
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Plan Show</title>
</head>
<body>
<h1>Show Plan</h1>
<div>
<h2><%= plan.title %></h2>
<div>
<a href="/plans/<%= plan.id %>/todos/new">Add Todo</a>
</div>
</div>
</body>
</html>
If everything works as expected you should commit your work.
git add . && git commit -m 'Update nested resources plans and todos'
We should have the following files:
server.js
const express = require('express');
const morgan = require('morgan');
const bodyParser = require('body-parser');
// load routes
const site = require('./routes/site');
const plans = require('./routes/plans');
const todos = require('./routes/todos');
const app = express();
const PORT = 8000;
app.set('view engine', 'ejs');
// Setup Middleware
app.use(morgan('combined'));
app.use(bodyParser.urlencoded({extended: true}));
app.use(bodyParser.json());
// Add routes
app.use('/', site);
app.use('/plans', plans);
app.use('/plans/:plan_id/todos', todos); // <--- Updated
app.listen(PORT, () => {
console.log('Server listening on ', PORT);
})
routes/todos.js
/*
RESTful todos
*/
const app = require("express").Router({ mergeParams: true });
const {
createTodo,
findTodos,
findTodo,
updateTodo,
destroyTodo
} = require("../models/todos");
/*
http --json \
GET 'http://localhost:8000/plans/:plan_id/todos'
*/
app.get("/", (req, res) => {
findTodos(req).then(todos => {
res.format({
"text/html": () => res.render("todos/index", { todos }),
"application/json": () => res.json(todos)
});
});
});
app.get("/new", (req, res) => {
// We enforce that it should be a number
const plan_id = req.params.plan_id;
res.render("todos/new", { plan_id });
});
/*
http --json \
GET 'http://localhost:8000/plans/:plan_id/todos/1'
*/
app.get("/:id", (req, res) => {
findTodo(req).then(todos => {
const todo = todos[0];
res.format({
"text/html": () => res.render("todos/show", { todo }),
"application/json": () => res.json(todo)
});
});
});
/*
http --json \
POST 'http://localhost:8000/plans/:plan_id/todos' \
title='A Short Title' description='A short description.'
*/
app.post("/", (req, res) => {
createTodo(req).then(todos => {
const todo = todos[0];
const { plan_id } = req.params;
res.format({
"text/html": () => res.redirect(`/plans/${plan_id}/todos/${todo.id}`),
"application/json": () => res.json(todo)
});
});
});
/*
http --json \
PATCH 'http://localhost:8000/plans/:plan_id/todos/1' \
title='COOOL!' description='WOOT!'
*/
app.patch("/:id", (req, res) => {
updateTodo(req).then(todos => res.json(todos[0]));
});
/*
http --json \
DELETE 'http://localhost:8000/plans/:plan_id/todos/1'
*/
app.delete("/:id", (req, res) => {
destroyTodo(req).then(() => res.sendStatus(204));
});
module.exports = app;
Thus far we haven't utilized any Ajax to load data onto a page. To begin this process we'll want to add the express static router.
server.js
// at the top of your app.use statements
app.use(express.static('public'))
Then we need to create the public
folder
mkdir -p public/{js,css,images}
Then we need to create a test js file.
touch public/js/plans.js
Then we can sketch out a script to help us fetch data from our server.
public/js/plans.js
function renderTodos(selector) {
const container = document.querySelector(selector);
const PLAN_ID = planId();
if (!PLAN_ID) { return null; }
fetchTodos(PLAN_ID)
.then(templateTodos)
.then((todos) => {
console.log(todos)
todos.forEach(todo => container.appendChild(todo))
});
}
function planId() {
const PLAN_ID = /^\/plans\/(\d+)\/?/;
const match = PLAN_ID.exec(window.location.pathname);
if (!match) { return null; }
return match[1];
}
function fetchTodos(planId) {
console.log('fetching')
return fetch(`/plans/${planId}/todos`, {
method: 'GET',
headers: { 'Accept': 'application/json' },
}).then(res => res.json());
}
function templateTodos(todos) {
return todos.map(todo => createEl(
'div', {
className: 'todo-item',
children: [
createEl('h3', { text: todo.title }),
createEl('p', { text: todo.description })
]
}
));
}
/*
createEl is a simple function to assist in creating DOM elements
@param tagName the name of the tag to create
@param options an object of class, text, or children
*/
function createEl(tagName, { className = '', text = '', children = []} ) {
const el = document.createElement(tagName);
el.appendChild(document.createTextNode(text));
el.setAttribute('class', className);
children.forEach(child => el.appendChild(child));
return el;
}
Then let's update our view to try to utilize this script on the frontend.
views/plans/show.ejs
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Plan Show</title>
</head>
<body>
<h1>Show Plan</h1>
<div>
<h2><%= plan.title %></h2>
<div>
<a href="/plans/<%= plan.id %>/todos/new">Add Todo</a>
</div>
</div>
<div id="todos"></div>
</body>
<script src="/js/plans.js" charset="utf-8"></script>
<!-- Calls our rendering script on the frontend -->
<script type="text/javascript">
renderTodos('#todos');
</script>
</html>
You should be able to go to http://localhost:8000/plans/:plan_id
and view any associated todo items on the same show page. Click the Add Todo
button on the page, submit a new todo, and then go back to http://localhost:8000/plans/:plan_id
to see the todo on the plan page.
If the script is working for you it's time we broke it down and explained what it's doing.
- You navigate in your browser to
http://localhost:8000/plans/:plan_id
- Your server receives a
GET /plans/:plan_id
- Your server renders a show page with the script above
- Your browser receives a response with HTML body containing your rendered plan page, and it begins parsing/rendering it to the dom, and then it reaches your
/js/plan.js
script source. - Your browser makes another request for the
/js/plan.js
- Your server looks inside the
public
folder for the script and sends the file back to the browser. - The browser begins executing the script, which just defines a few functions (to be discussed later).
- The browser moves onto the next script and executes the
renderTodos
function. This function tells the browser to fetch our todos from the server and append each of the to the DOM using DOM manipulation techniques. Note that the fetch request executed by the browser somehow has to figure out whatplan_id
to use when getting todos from the server.
Be sure you're clear about the above steps. We will now go deeper into the browser execution of the scripts.
Let's first breakdown the functions in your plan.js
function renderTodos(selector) {
// Selects the #todos div from the DOM
const container = document.querySelector(selector);
// Parses the plan_id from the browser address bar (MAGIC!?)
const PLAN_ID = planId();
// GUARDS the rest of the code from executing if no plan_id
if (!PLAN_ID) { return null; }
fetchTodos(PLAN_ID) // REQUESTS all todos for the current plan
.then(templateTodos) // Constructs DOM nodes for each todo
.then((todos) => { // Appends each todo DOM node to the container above
console.log(todos)
todos.forEach(todo => container.appendChild(todo))
});
}
The first function you'll notice is the planId
function. It extracts the plan id from the browser address bar.
function planId() {
// Defines a regexp to match /plans/:some_number
const PLAN_ID = /^\/plans\/(\d+)\/?/;
// Tries to match the url match to the browser path
// window.location.pathname returns the path from the browser
const match = PLAN_ID.exec(window.location.pathname);
// If we can't get a match then we return null
// We should probably do better error handling here.
if (!match) { return null; }
// Returns the matching number from the path
return match[1];
}
The fetchTodos
function is really just making a request. See MDN for details on fetch and body parsing JSON.
The templateTodos
function is just creating DOM nodes for each todo to be appended to the page.
function templateTodos(todos) {
return todos.map(todo => createEl(
'div', {
className: 'todo-item',
children: [
createEl('h3', { text: todo.title }),
createEl('p', { text: todo.description })
]
}
));
}
Is very much just generating some HTML like the following:
<div class="todo-item">
<h3>Talk To Parents</h3>
<p>
Catch up on the holidays and find out when they are visiting next.
</p>
</div>
The createEl
function utilizes our DOM manipulation methods to create and modify an element to have the provided properties.
Now that you've reviewed adding some Ajax to one of your pages it's time to commit.
git add . && git commit -m 'Update plans show w/ dynamic todos'
Our users probably don't want to navigate to separate pages just to see a form. We can consolidate this form into our views/plans/show.ejs
Now we can focus on handling the form submission. Add the following code to the top of your plans.js
.
public/js/plans.js
function handleSubmit(selector, todosCon) {
const form = document.querySelector(selector);
const container = document.querySelector(todosCon);
form.addEventListener('submit', (e) => {
e.preventDefault();
createAndAppend(form, container);
});
}
function createAndAppend(form, container) {
const newTodo = entriesToObject(extractFormEntries(form));
createTodo(planId(), newTodo)
.then(todo => templateTodos([todo])[0])
.then(todo => container.appendChild(todo))
.then(() => form.reset())
}
function createTodo(plan_id, data) {
return fetch(`/plans/${plan_id}/todos`, {
method: 'POST',
headers: {
'Content-type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(data)
}).then(res => res.json());
}
function entriesToObject(entries) {
const obj = {};
for (let [key, value] of entries) {
obj[key] = value;
}
return obj;
}
function extractFormEntries(form) {
return new FormData(form).entries();
}
Most of this code is from our bird tracker and just allows us to quickly serialize a form and post it to our server as JSON.
You can see an outline of the magic in the handleSubmit
function.
function handleSubmit(selector, todosCon) {
// Grab form element from DOM
const form = document.querySelector(selector);
// Grab todos container from DOM
const todos = document.querySelector(todosCon);
// Listen for form submission
form.addEventListener('submit', (e) => {
// Prevent form from reloading page
e.preventDefault();
// Create the form on the server and append the result to the page
// Note: if you'd prefer a prepended result you can refactor
createAndAppend(form, todos);
});
}
We can peek inside the createAndAppend
to see what's going on.
function createAndAppend(form, container) {
// Serialization logic to turn form into Object
const newTodo = entriesToObject(extractFormEntries(form));
createTodo(planId(), newTodo) // POST newTodo to /plans/:plan_id/todos
.then(todo => templateTodos([todo])[0]) // Turn todo into DOM element
.then(todo => container.appendChild(todo)) // Append the todo DOM element
.then(() => form.reset()) // Clear the form data
}
Let's begin by adding the todo form to plans show page.
views/plans/show.ejs
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Plan Show</title>
</head>
<body>
<h1>Show Plan</h1>
<div>
<h2><%= plan.title %></h2>
<div>
<a href="/plans/<%= plan.id %>/todos/new">Add Todo</a>
</div>
</div>
<form id="todoForm">
<div>
<input type="text" name="title"/>
</div>
<div>
<textarea name="description"></textarea>
</div>
<button type="submit">Save Todo</button>
</form>
<div id="todos"></div>
</body>
<script src="/js/plans.js" charset="utf-8"></script>
<script type="text/javascript">
renderTodos('#todos');
handleSubmit('#todoForm', '#todos');
</script>
</html>
If you are keen enough to travel to another model you'll notice that each plan seems to have the same todos. This is because we haven't updated our model to return only the specified plan_id's todos.
Update your findTodos to look like the following:
models/todos.js
// Find all
function findTodos({params: {plan_id}}) {
return knex("todos").where({plan_id});
}
You might now be wondering if you should utilize the plan_id
in your remaining methods:
- findTodo
- updateTodo
- destroyTodo
It's really up to you at this point. A todo's id should be enough to uniquely identify it. You would definitely refactor them now if you wanted to enforce someone knew the actual plan_id
before they could perform CRUD on a nested resource.
Time to commit these changes
git add . && git commit -m 'Fix findTodos to utilize plan_id'
Let's add a delete button to our todos. This should just be a quick refactor of our Todo templating.
public/js/plans.js
function templateTodos(todos) {
return todos.map(todo => {
const todoEl = createEl(
'div', {
className: 'todo-item',
children: [
createEl('h3', { text: todo.title }),
createEl('p', { text: todo.description }),
createEl('button', {
className: 'delete-btn',
text: 'delete',
on: {
click: (e) => deleteTodo(todo, todoEl),
}
}),
]
});
return todoEl;
});
}
Note how we were able to add a click handler onto our button. We did this by updating our createEl
function.
public/js/plans.js
function createEl(tagName, {
className = '',
text = '',
children = [],
on = {} // THIS IS NEW
}) {
const el = document.createElement(tagName);
// We iterate through event names and attach their handlers
Object
.entries(on)
.forEach(([event, handler]) => el.addEventListener(event, handler))
el.appendChild(document.createTextNode(text));
el.setAttribute('class', className);
children.forEach(child => el.appendChild(child));
return el;
}
We also add our deletion logic in a function called deleteTodo
.
public/js/plan.js
function deleteTodo(todo, todoEl) {
fetch(`/plans/${planId()}/todos/${todo.id}`, {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'Content-type': 'application/json',
}
}).then(() => todoEl.remove())
}
You should now be able to delete todo items. Let's commit this work.
git add . && git commit -m 'Update planjs to delete todos'
The complete planJS looks like the following:
public/js/plan.js
function handleSubmit(selector, todosCon) {
const form = document.querySelector(selector);
const container = document.querySelector(todosCon);
form.addEventListener('submit', (e) => {
e.preventDefault();
createAndAppend(form, container);
});
}
function createAndAppend(form, container) {
const newTodo = entriesToObject(extractFormEntries(form));
createTodo(planId(), newTodo)
.then(todo => templateTodos([todo])[0])
.then(todo => container.appendChild(todo))
.then(() => form.reset())
}
function createTodo(plan_id, data) {
return fetch(`/plans/${plan_id}/todos`, {
method: 'POST',
headers: {
'Content-type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(data)
}).then(res => res.json());
}
function entriesToObject(entries) {
const obj = {};
for (let [key, value] of entries) {
obj[key] = value;
}
return obj;
}
function extractFormEntries(form) {
return new FormData(form).entries();
}
function renderTodos(selector) {
const container = document.querySelector(selector);
const PLAN_ID = planId();
if (!PLAN_ID) { return null; }
fetchTodos(PLAN_ID)
.then(templateTodos)
.then((todos) => {
console.log(todos)
todos.forEach(todo => container.appendChild(todo))
});
}
function planId() {
const PLAN_ID = /^\/plans\/(\d+)\/?/;
const match = PLAN_ID.exec(window.location.pathname);
if (!match) { return null; }
return match[1];
}
function fetchTodos(planId) {
console.log('fetching')
return fetch(`/plans/${planId}/todos`, {
method: 'GET',
headers: { 'Accept': 'application/json' },
}).then(res => res.json());
}
function templateTodos(todos) {
return todos.map(todo => {
const todoEl = createEl(
'div', {
className: 'todo-item',
children: [
createEl('h3', { text: todo.title }),
createEl('p', { text: todo.description }),
createEl('button', {
className: 'delete-btn',
text: 'delete',
on: {
click: (e) => deleteTodo(todo, todoEl),
}
}),
]
});
return todoEl;
});
}
function deleteTodo({id}, todoEl) {
fetch(`/plans/${planId()}/todos/${id}`, {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'Content-type': 'application/json',
}
}).then(() => todoEl.remove())
}
/*
createEl is a simple function to assist in creating DOM elements
@param tagName the name of the tag to create
@param options an object of class, text, or children
*/
function createEl(tagName, {
className = '',
text = '',
children = [],
on = {}
}) {
const el = document.createElement(tagName);
Object
.entries(on)
.forEach(([event, handler]) => el.addEventListener(event, handler))
el.appendChild(document.createTextNode(text));
el.setAttribute('class', className);
children.forEach(child => el.appendChild(child));
return el;
}