Build a Recipe App With Node.js & PostgreSQL - List Recipes Part 2

Build a Recipe App With Node.js & PostgreSQL - List Recipes Part 2

This is the second part of Build a Recipe App With Node.js & PostgreSQL series. In this part will will list recipes and implement search.

Parts

Adding handlebars

Update app.js with the following content.

const express = require('express');
const exphbs = require('express-handlebars');
const methodOverride = require('method-override');
const helpers = require('handlebars-helpers');
const db = require('./db');

const app = express();
const port = 3000;


app.engine('handlebars', exphbs({ helpers: [helpers.comparison()] }));
app.set('view engine', 'handlebars');
app.use(express.static(`${__dirname}/public`));
app.use(methodOverride('_method'));

app.get('/', async (req, res) => {
  const queryResult = await db.query('select * from recipe');
  res.send(queryResult.rows);
});

app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`);
});

Here we import some new dependencies, after that we configure handlebars to work with express. handlebars-helpers add some extra helper that is not included in the box by handlebars.

app.use(express.static(`${__dirname}/public`));

Here we setup a public folder, and all the files we want to be visible for the client we have to put here. For example css, images, client javascript and so on.

app.use(methodOverride('_method'));

With this we can now use form methods like put and delete in our templates.

Render a list of recipes

In the root of the project create a folder called views after that create another folder inside views called layouts.

Inside the layouts folder, we will create a file called main.handlebars.

/views/layouts/main.handlebars

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Recipe Book</title>
    <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
    <link rel="stylesheet" href="/css/main.css">
</head>
<body>
    <nav>
        <div class="container">
            <div class="nav-wrapper">
                <a href="/" class="brand-logo">Recipe Book</a>
                <ul id="nav-mobile" class="right hide-on-med-and-down">
                    <li><a href="/">Recipes</a></li>
                    <li><a href="/add-recipe">Add recipe</a></li>
                </ul>
            </div>
        </div>
    </nav>
    {{{body}}}
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
    <script src="/js/main.js"></script>
</body>
</html>

All other templates will extend from this layout file. We put our links to stylsheets and our script tags here. We also have a navbar so we don't have to repeat that on all other handlebars files.

Create css folder in the public folder with a file called main.css.

/public/css/main.css

.space-top {
  margin-top: 2rem;
}

.description-container {
  display: flex;
  justify-content: space-between;
  align-content: center;
  flex-direction: row-reverse;
}

.recipe-card{
  background-size:cover;
  background-position:center;   
  height: 20rem;
}

.card a {
  color: inherit;
}

.instructions {
  margin-top: -1.5rem;
  white-space: pre-line;
}

@media only screen and (max-width: 768px) {
  .description-container {
      display: block;
  }

  .description-container form {
      margin-bottom: 2rem;
  }
}

This is just some extra css to make the app look a bit nicer.

Create a new file in the views folder called home.handlebars with the following content.

/views/home.handlebars

<div class="container space-top">
    <div class="row">
        <form method="get" class="col s12">
            <div class="row">
                <div class="input-field col l6 s12 offset-l3">
                    <i class="material-icons prefix">search</i>
                    <input autofocus value="{{search}}" id="icon_prefix" name="search" type="text" class="validate">
                    <label for="icon_prefix">Search</label>
                </div>
            </div>
        </form>
    </div>
    <div class="row">
        {{#each recipes}}
            <div class="col l4 s12">
                <div class="card">
                    <a href="/recipes/{{this.id}}">
                        <div class="card-image recipe-card" style="background-image:url(images/{{this.image}});">
                        </div>
                        <div class="card-content">
                            <span class="card-title">{{this.name}}</span>
                            <p>{{this.description}}</p>
                        </div>
                    </a>
                </div>
            </div>
        {{/each}}
    </div>
</div>

Here we are using Materlize to setup a grid of cards for our recipe. We also setup search input that will send a request to the express server with the search query.

Update the route in app.js with the following code.

app.get('/', async (req, res) => {
  const queryResult = await db.query('select * from recipe');
  res.render('home', { recipes: queryResult.rows });
});

We now tell express to render a template called home, and because we setup handlebars before it will be able to find the template. We also pass the result from our query to the template.

(Don't forget to run npm run dev if you have not done it yet.)

You can now visit the browser and you should see a list of recipes being rendered.

To be able to search for recipes we have to pass the search string to our query, but before we do that let's refactor little bit.

To keep the code a little bit more organized we will have a file with all our queries to the database. Create a new file in the root called queries.js and add the following code.

const db = require('./db');

async function getAllRecipes(search = '') {
  const query = await db.query({
    text: 'select * from recipe where lower(name) like lower($1) ORDER BY created_at DESC;',
    values: [`%${search}%`],
  });

  return query.rows;
}

module.exports = {
  getAllRecipes,
};

Here we are using something called parameterized query instead of using string concatenating to protect from sql injection. This query will select all recipes with a name matching the search string. We use lower() to make sure casing does not matter in the search.

Update app.js with the following code.

const express = require('express');
const exphbs = require('express-handlebars');
const methodOverride = require('method-override');
const helpers = require('handlebars-helpers');
const queries = require('./queries');

const app = express();
const port = 3000;

app.engine('handlebars', exphbs({ helpers: [helpers.comparison()] }));
app.set('view engine', 'handlebars');
app.use(express.static(`${__dirname}/public`));
app.use(methodOverride('_method'));

app.get('/', async (req, res) => {
  const search = req.query.search || '';
  const recipes = await queries.getAllRecipes(search);

  res.render('home', { recipes, search });
});

app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`);
});

To be able to get the search query from the frontend we can use req.query.search if it's not provided we default to an empty string. We then pass it to the getAllRecipes, as the last step we pass the search string to our template so the user can see what they searched for when they get the result back. If you now reload the browser you should be able to search for recipes.

Recipe app search list

Conclusion

In this part of the series we setup a view to display all our recipes and search functionality. In the next part we will create a view to show detail about a recipe.

Source code part 2