Skip to content

Instantly share code, notes, and snippets.

@amcooper
Created January 23, 2018 22:49
Show Gist options
  • Save amcooper/23b40125c440d93ec819c235c37d6a8e to your computer and use it in GitHub Desktop.
Save amcooper/23b40125c440d93ec819c235c37d6a8e to your computer and use it in GitHub Desktop.

Knex - Express - CRUD

Practicing Relationships

Quickly Setup Your Project

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 commit

    touch README.md && git add . && git commit -m 'Add README'
  • Initialize the npm project

    touch server.js
    npm init -y
    echo 'node_modules' > .gitignore
    git add .  && git commit -m 'Init an npm project'
  • Install knex and pg

    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

Table Creation

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'

Questions

  • What are the following postgres tools for createdb and dropdb?
  • What is the intended purpose of migrate:latest and migrate:rollback?

RESTful Todos

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'

Questions

  • 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 and bodyParser.urlencoded allow us to achieve?

Routing

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.

Questions

  • List out the RESTful paths and methods for a kittens resource

Querying Our Database

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'

Exercises

Start your server and verify each of the httpie requests works correctly.

Handling HTML Views

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'

Exercises

  • Add an /about page in your routes/site.js
  • Add a /contact page in your routes/site.js
  • Update your views/site/home.ejs adding hyperlinks to your /about and /contact.

Adding More HTML

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'

Exercises

A Todo Form

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'

Adding A Show Page

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'

Redirecting

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'

Exercises

  • Add hyperlinks to your todos/:id to review all todos at /todos.

Review Current Files

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);
})

Todo Lists

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 a todo.
  • 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.

Adding Plan References

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'

Refactoring Our DB Helpers

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.

Adding Routes For Plans

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'

Plan Views

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>

New

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>

Show

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'

Nesting Resources

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 called Test.
  • 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;

Adding Ajax

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 what plan_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'

Using A Form

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>

Refactoring Our Todo Model

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'

Deleting Todos

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;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment