Hello! This repo is for tracking and documenting the lessons from Wes Bos's Learn Node course. I also include summaries of lessons learned after each topic.
- npm install FTW!
- install pug file highlighting for code editor
- two ways to setup Mongo:
- use a DBAAS (database as a service) like mLab.com
- create a new mongodb deployment
- pick FREE sandbox option (shared, 0.5GB)
- copy the connection string from the deployment and paste in variables.env under database
- create a user with a password in mLab and replace the
<dbuser>and<dbpassword>part of the connection string
- host it yourself (mac)!
- get homebrew if you don't have it
brew updatebrew install mongodb
- use a DBAAS (database as a service) like mLab.com
- Express is a fast, un-opinionated, minimalist web framework for Node.JS
- doesn't do a lot by itself, so we pick and choose from the Node ecosystem
- in the app.js file, we pull different modules from Node to make our app work
- in the start.js file
- we import Mongoose, which is how we interface with our database
- we import our environmental variables, which is stored in a variables.env file, and contains sensitive information like passwords/usernames, API keys, tokens - anything you need to keep safe.
- it's a .env file because it should never go into a GitHub Repo
- we connect to our database through Mongoose, enable compatibility with ES6 promises, and watch for errors
- finally we require the app and kick off the server
- we can check package.json to see the available npm scripts
- use npm start which will do the following:
- watch javascript files with Nodemon and kill and restart the server automatically
- run assets, which will use Webpack to compile Sass into CSS and compile the frontend Javascript into a bundle for us
- use npm start which will do the following:
- in the terminal, the logs coming from the computer have a computer icon, while the logs from Webpack have a box icon
- Essentially when people go to a URL, you need to do stuff!
- query the database
- filter through a list of stores
- modify that data in some way
- finally send the data to the user
- Express.js routing
- in index.js
- import Express
const express = require('express'); - grab the router off of Express
const router = express.Router(); - define all your routes
router.get('/', (req, res) => { res.send('Hey! It Works!'); });
- when somebody visits the URL ('/'), the function is going to give you three things
- request
req, which is an object full of information that's coming in - response
res, which is an object full of methods for sending data back to the user - next
next, which will be handled by middleware file
- request
- import Express
- in app.js
- import routes from index.js
const express = require('./routes/index'); - then tell Express to use those routes
app.use('/', routes):- can do a separate admin router too
app.use('/admin' adminRoutes);
- can do a separate admin router too
- handles Middleware as well
app.use(bodyParser.json());andapp.use(bodyParser.urlencoded({ extended: false }));takes the data in the request, checks the URL, and puts all of the data so we can easily access it throughrequest.queryorrequest.body
- import routes from index.js
- in index.js
- two ways to send data to the browser
res.sendandres.json- watch for error "Headers are already sent" - probably sent data twice e.g.
res.sendwithres.json
- watch for error "Headers are already sent" - probably sent data twice e.g.
- data from the url
localhost:7777/?name=wes&age=100is in the requestreq.query.name - putting variables in a route
router.get('/reverse/:name') - obtaining variables in a route
req.params.name
res.renderis going to render out a template.- PUG is very popular templating language in the Node community
- used to be called Jade
- in
app.js- we set the folder where we keep our PUG files, AKA our Views folder
- we set the templating engine to be PUG
- mustache and EJS are other templating engines
- in
index.jsres.render()will take two things- a name of a template to render out, AKA the View
- an object whose properties define local variables, AKA locals
res.render('hello', { name: 'wes', dog: 'ziggy' })
- can also pass info from the url -
localhost:7777/?dog=ziggyres.render('hello', { name: 'wes', dog: req.query.dog })
- PUG basics
- no tags! just type
p hello! - nest elements by indenting one level
.wrapperwill create automatically assume<div class="wrapper"></div>img(src="dog.jpg" alt="Dog")for attributes- if you don't want a tag
<hello></hello>, but just want to place text, use a pipe| hello
- if you pass in locals, you can interpolate the variable with
#{}p.hello Hello my dogs name is #{dog}
- if you want to pass in locals to an attribute, you must use javascript denoted with backticks and ESX template literals
${}img.dog(src="dog.jpg" alt=`${Dog}`)
- if you want to define variables
- const upDog = dog.toUpperCase();
- if you want to use JavaScript
p.hello Hello my dogs name is #{dog.toUpperCase()}
- emmet works too!
- we have a
layout.pugfile that we can "extend" a default layout if we want to create a fresh new page- simply type
extends layoutto import the layout
- simply type
- we also can have different "blocks", which are sections of your website that can be filled in by another template
- no tags! just type
- in
helpers.js- contains any helper libraries or data that's needed in every single template
- in
app.js- we import the data from
helpers.jsby requiring it at the top of the fileconst helpers = require('./helpers');
- then we add it to our locals
res.locals.h = helpers- which we can then reference in PUG
title= `${title} | ${h.siteName}`
- we import the data from
- any sensitive data should still be stored in
variables.env
- MVC Design Pattern - a way to architect and organize your code
- Model - manages the data from the database
- View - templates in the PUG files
- Controller - 'traffic cop' between Model and View; gets the data from the Model and puts it into the template
- helps in larger applications to avoid messy, hard to test code and increase reusability
- each functional part of the application will have its own controller
- in
storeController.js- we add a homePage method to the global exports variable
exports.homePage = (res, req) => { res.render('index'); }
- in
app.js- we require the storeController at the top, and add the homePage method to the route
const = storeController = require('../controllers/homePage'); router.get('/', storeController.homePage);
- middleware allows us to run code after the request but before the response actually happens
- e.g. user authentication
- a request is made when a user signs in
- bodyParser makes the data available on the req object
req.body.email = " wes@wesBOS.com "; - emailNormalize prepares/validates data
req.body.email = req.body.email.trim().toLowerCase(); - authorizeUser looks up user and checks the password
req.user = { name: 'Wes', email: 'wes@wesbos.com'}req.user.email; // "wes@wesbos.com" - valid - displayProfile and render template
res.render('account', { user: req.user }) - invalid - flash error and redirect to login page
res.flash('error', 'Invalid Login');res.redirect('/login')
- e.g. user authentication
- middleware can have a
next();method which is used to say, "I'm done with this middleware, pass it on to the next function down the line" - e.g. myMiddleware completes and continues to homePage
router.get('/', storeController.myMiddleware, storeController.homePage); - in
app.js- anytime we see
app.usewe are using global middleware - so even before we expose any of our routes, we're running all this middleware
- we have a public folder filled with static assets, so anytime anyone asks for any of these images, express doesn't even have to think about if it's a route or not
app.use(express.static(path.join(__dirname, 'public'))); - takes the raw request and turns them into usable properties on the
req.bodyapp.use(bodyParser.json());app.use(bodyParser.urlencoded({ extended: false })) - populate
req.cookieswith any cookies that came along with the requestapp.use(cookieParser()); - sessions all us to store data on visitors from request to request
app.use(session({ secret: process.env.SECRET, key: process.env.KEY, resave: false, saveUninitialized: false, store: new MongoStore({ mongooseConnection: mongoose.connection }) }));
- we have a public folder filled with static assets, so anytime anyone asks for any of these images, express doesn't even have to think about if it's a route or not
- Passport JS handles our logins
app.use(passport.initialize()); app.use(passport.session());
- pass variables to our templates and all requests
app.use((req, res, next) => { res.locals.h = helpers res.locals.flashes = req.flash(); res.locals.user = req.user || null; res.locals.currentPath = req.path; next(); });
- then we finally handle our own routes
app.use('/', routes); - and if that didn't work, 404 them and forward to error handler
app.use(errorHandlers.notFound)
- anytime we see
- error handlers are safety nets to catch stuff after trying our routes, but before sending the response
- models is where are data is going to be stored
- mongoDB can be a loose database, meaning you do not need to specify what your data will look like ahead of time, but by default it is strict
- in new file ./models/Store.js
- mongoDB is the database and mongoose is the package that we use to interface
const mongoose = require('mongoose'); - we set the mongoose promise to be the global promise to use ES6's async/await
mongoose.Promise = global.Promise; - we use slugs to turn our urls into url-friendly-names
const slug = require('slugs'); - we create a new schema
const storeSchema = new mongoose.Schema({ // can accept an object with multiple parameters name: { type: String, // do data normalization as close to the model as possible trim: true, // instead of true, pass a better user error message required: 'Please enter a store Name!' }, slug: String, description: { type: String, trim: true }, // we'll pass multiple strings in an Array tags: [String] });
- finally we export the schema with
module.exportssince it's the main thing we're exportingmodule.exports = mongoose.model('Store', storeSchema);
- mongoDB is the database and mongoose is the package that we use to interface
- in
start.js- we import all our models
- only need to import once and MongoDB will know about the models throughout the entire application
require('./models/Store');
- only need to import once and MongoDB will know about the models throughout the entire application
- we import all our models
- back in
Store.js- we need to "slugify" our store name before we save, but only if the name has been modified
storeSchema.pre('save', function(next) { if (!this.isModified('name')) { next(); // skip it return; // stop this function from running } this.slug = slug(this.name); next(); })
- in
index.js- create a route for an Add page
router.get('/', store.controller.addStore)
- create a route for an Add page
- in
storeController.js- add a new controller
exports.addStore = (req, res) => { res.render('editStore', { title: 'Add Store', }); }
- name the view 'editStore' instead of 'addStore', since we'll use the same template for both the Edit Store page and Add Store page
- pass in a page title 'Add Store'
- in
editStore.pug- import the base page layout from
layout.pugextends layout - select the main body section
block content h2= titleandh2 #{title}are the same thing- title variable was imported from the controller and is in our locals
- import the base page layout from
- a PUG mixin is like a function where you pass in some data and it returns some HTML
- in
_storeForm.pug- underscore in the file name to keep similar conventions to sass
- add a mixin named storeForm
mixin storeForm(store = {}) - for adding a store, the default will be an empty object, but when we need to edit the store, a store name will be passed in
- in
editStore.pug- import the mixin
include mixins/_storeForm - use the mixin with a plus sign (+)
+storeForm()
- import the mixin
- in
editStore.pug- use a dash (-) to use javascript
- const choices = ['Wifi', 'Open Late', 'Family Friendly', 'Vegetarian', 'Licensed']; - use for loops
each choice in choices
- use a dash (-) to use javascript
- two form methods "POST" and "GET"
- "POST" sends the data invisibly, useful for passwords
- "GET" sends the data through the URL
- in
storeController.js- import mongoose
const mongoose = require('mongoose'); - import the Store schema, which rather than importing the schema directly from the file, we've already imported it once in
start.js, so we can simply reference it off the mongoose variable (a concept called singleton). We export this specific model 'Store' at the bottom of ourStore.jsfileconst Store = mongoose.model('Store'); - create a new store
exports.createStore = (req, res) => { const store = new Store(req.body); }
- what if someone posts additional data in the request?
- since we're using a strict schema in our model in
models/store.js, only the defined data will be picked up. Anything else will get thrown away.
- since we're using a strict schema in our model in
- save a store, which will fire off a connection to the MongoDB database, save that data, and then come back to us with either the store itself or an error saying what happened
store.save();
- import mongoose
- 3 ways to deal with asynchronous data
- callbacks, which is the old way and has a lot of syntax and can create christmas tree code or callback hell
store.save(function(err, store) { if(!err) { console.log('It worked!'); res.redirect('/'); } })
- promises, which you use
.then()and.throw()and is great because you can chain these things on and on, as long as they return a promise from each of the thens
store .save() .then(store => { return Store.find() }) .then(stores => { res.render('storeList', { stores: stores }) }) .catch(err => { throw Error(err); }) console.log('It worked!');
- async await, which you directly tell the parent function it will have some awaits, and then it will wait to complete the awaited function before moving on to the next line
exports.createStore = async (req, rest) => { const store = new Store(req.body); await store.save(); console.log('It worked!'); }
- in
errorHandlers.js- in order to avoid
try{} catch(e) {}with async await in each controller , we use some middleware to wrap the function in - this will catch the error, and call
next(), which will run the next function inapp.js
exports.catchErrors = (fn) => { return function(req, res, next) { return fn(req, res, next).catch(next); } }
- in order to avoid
- in
index.js- import the catchErrors handler with ES6 object de-structuring, which will only import that specific method from
errorHandlers.jsconst { catchErrors } = require('../handlers/errorHandlers'); - wrap the controller with the catchErrors
router.post('/', catchErrors(storeController.createStore));
- import the catchErrors handler with ES6 object de-structuring, which will only import that specific method from
- flashing a message helps with redirects by displaying additional info such as a success or error message
- in
app.js- we import the flash middleware
const flash = require('connect-flash'); - and make the flashes available
app.use(flash()); - and store them in our locals too
req.locals.flash = req.flash();
- we import the flash middleware
- in
storeController.jsand in our createStore method- we use the appropriate flash type and specify the text
req.flash('success', `Successfully created ${store.name}. Care to leave a review?`) - other flash types available - 'warning', 'danger', 'info'
- we redirect them to a store page
res.render(`/store/${store.slug}`) - since the slug is autogenerated in the model, we await and call
.save()on the function right awayconst store = await (new Store(req.body)).save();
- we use the appropriate flash type and specify the text
- in
layout.pugand in the messages block- we check first if there's any flashes in locals
- then we loop over each category, and each message within the category to have a class of
.flashand.flash--category - we add a paragraph tag with a class of
.flash__textand parse the message with a!=- regular
=does not parse HTML, so you would end up withsomething <strong>happened</strong>
- regular
block messages if locals.flashes .inner .flash-messages - const categories = Object.keys(locals.flashes) each category in categories each message in flashes[category] .flash(class=`flash--${category}`) p.flash__text!= message button.flash__remove(onclick="this.parentElement.remove()") ×
- whenever you want a quick dump of the data being sent through, just use a pre tag and a helper method dump in the pug file
pre= h.dump(locals)- the helper function comes from
helpers.jsexports.dump() = (obj) => JSON.stringify(obj, null, 2);
- the helper function comes from
- flashes only work with sessions, as you have to be able to save data from one request to another
- otherwise the application is stateless
- need a way to display the stores on the homepage and on the stores page
- in
storeController.js- create a getStores controller method
exports.getStores = (req, res) => { res.render('stores', { title: 'Stores' }); }
- in
index.js- change the controller method for the index to getStores
router.get('/', 'storeController.getStores); - create a new controller method for the stores page
router.get('/stores', 'storeController.getStores);
- change the controller method for the index to getStores
- create a new view
stores.pug- with a standard layout
extends layout block content .inner h2= title
- before we show the stores on the page, we need to query the database for a list of all stores
- in
index.jswe'll also need to wrap the controller in catchErrors
- in
- in
storeController.js- we'll async the controller method
- query the database with Store.find()
- and make the stores available to our view by passing a stores variable
exports.getStores = async (req, res) => { const stores = await Store.find(); res.render('stores', { title: 'Stores', stores: stores }) }
- in
stores.pug- now that we have the stores data made available by the controller, display the store in the template
.stores each store in stores
- create a new mixin called
_storeCard.pug- the mixin will take in a store
mixin storeCard(store = {}) .store
- it will have a hero with the store's image or default to a generic photo
.store__hero img(src=`/uploads/${store.photo || 'store.png'}`)
- add a h2 title and a link to the store
h2.title a(href=`/store/${store.slug}`) #{store.name}
- add a store details section and use only trim the description to 25 words max
.store_details p= store.description.split(' ').slice(0, 25).join(' ')
- also in the store hero, add in a store actions subsection (eventually will feature like, follow, and remove buttons)
.store__hero .store__actions button soon
- in
stores.pug- import the mixin at the top
include mixins/_storeCard
- use the mixin in each store
each store in stores +storeCard(store)
- in
_storeCard.pug- create an edit button that links to an edit page for that store
- we uniquely link to each store through the _id, which is auto generated by MongoDB
- in
helpers.jswe have an svg helper functionh.icon(), which makes the SVG code available to customize (can also use SVG sprites - use
!=to evaluate javascript and output HTML (unsafe for user input)
.store__actions .store__action.store__action--edit a(href=`/store/${store._id}/edit`) != h.icon('pencil')
- in
index.js- create a route to handle editing the page
- use
:to do a wildcard route
router.get('/stores/:id/edit', catchErrors(storeController.editStore));
- in
storeController.js- make a new controller called editStore
- fine the store given the ID
- use
findOne()to query the database based on the ID, which we can find in our params - remember to use await, as
findOne()as with most of the DB functions return a promise
- use
- render out the edit form so the user can update their store
- we'll use the same view that we used for the addStore controller, but we'll pass in a store as well to make it available to
_storeForm.pug
- we'll use the same view that we used for the addStore controller, but we'll pass in a store as well to make it available to
exports.editStore = async (req, res) => { const store = await Store.findOne({ _id: req.params.id }); res.render('editStore', { title: `Edit ${store.name}`, store: store }) }
- in
_storeForm.pug- add the store name and description to the form
label(for="name") Name input(type="text" name="name" value=store.name) label(for="description") Description textarea(name="description")= store.description
- create a tags variable which will hold the store tags or default to an empty array
const tags = store.tags || []
- update the tags by including a checked attribute and using
()to run some JavaScript
input(type="checkbox" id=choice value=choice name="tags" checked=(tags.includes(choice)))
- update the action attribute on the form to accomodate for both adding and editing the store
form(action=`/add/${store._id || ''}` method="POST" class="card")
- in
index.js- create a new route to handle requests to update the store
router.post('add/:id', catchErrors(storeController.updateStore));
- in
storeController.js- create a new controller for updating the store
- use
findOneAndUpdate(filter, update, options)to find the store and update it in one function- we'll filter by the store id, which is found in our params
- we'll update it with the new info from the request object
- we'll provide options to
- return the new store (the default option would instead return the old store)
- run our database validators again (which are only checked upon creation)
- use
exec()at the end to make sure we actually run the query
- flash a success message
- redirect them to the page
exports.updateStore = async (req, res) => { const store = await Store.findOneAndUpdate({ _id: req.params.id }, req.body, { new: true, runValidators: true }).exec(); req.flash('success', `Successfully updated <strong>${store.name}</strong>. <a href="/store/${store.slug}">View Store ->`</a> ); res.redirect(`/store/${store._id}/edit`); }
- in
/models/Store.js- really important before you start storing data to figure out how your data should be stored
- add a created property
created: { type: Date, default: Date.now }
- mongoDB has a huge feature set for dealing with stuff that is location based
- add a location property
location: { type: { type: String, default: 'Point' }, coordinates: [{ type: Number, required: 'You must supply coordinates!' }], address: { type: String, required: 'You must supply an address!' } }
- in
/views/mixins/_storeForm.pug- use
//-for pug comments - add a label and input for the address
- in
app.jswe have middlewarebodyParser.urlencoded({ extended: true })that allows us to send nested data without having to do any extra heavy lifting on the client side before we send it, or on the server side as we receive that data. - to avoid error "Cannot read property 'address' of undefined" when we have a store with no location data, we use parentheses to check for the property first
- in
label(for="address") Address input(type="text" id="address" name="location[address]" value=(store.location && store.location.address))
- add a label and input for the lng and lat
- be sure that lng comes first and lat comes second since that's the way MongoDB expects the data to be stored
label(for="lng") Address Lng input(type="text" id="lng" name="location[coordinates][0]" value=(store.location && store.location[coordinates][0]) required) label(for="lat") Address Lat input(type="text" id="lat" name="location[coordinates][1]" value=(store.location && store.location.coordinates[1]) required)
- use
- in
public/javascripts/modules/bling.js- gives us a jQuery type syntax with
$and we can do stuff like$('.wrapper').on('click')
- gives us a jQuery type syntax with
- create a new file
modules/autocomplete.js- create a new function autocomplete and export it
- even though we don't have ES6 syntax on Node, it is available through webpack
function autocomplete (input, latInput, lngInput) { } export default autocomplete;
- create a new function autocomplete and export it
- in
public/javascripts/delicious-app.js- import autocomplete and pass in the selectors for each corresponding field in our form with the Bling syntax
import './modules/autocomplete' autocomplete( $('#address'), $('#lat'), $('#lng') );
- back in
autocomplete.js- we'll first check if there's an input, and if not just return the function to skip the function from running
- create the dropdown for the address field
- listen for a change to the address field, and set the lat and lng field values accordingly
- prevent enter key from submitting the form on the address field
function autocomplete(input, latInput, lngInput) { if (!input) return; const dropdown = new google.maps.places.Autocomplete(input); dropdown.addListener('place_changed', () => { const place = dropdown.getPlace(); latInput.value = place.geometry.location.lat(); lngInput.value = place.geometry.location.lng(); }); input.on('keydown', (e) => { if(e.keyCode === 13) e.preventDefault(); }); }
- in MongoDB Compass
- you can vizualize the location data in the store schema
- but when you update the address, some defaults in the store schema do not kick in
- in
storeController.js- set the location's type to default to a point
exports.updateStore = async (req, res) => { req.body.location.type = 'Point'; }
- modify store form to allow uploading an image
- add some middleware to upload the file and resize the file
- in
_storeForm.pug- anytime you're uploading files to a server, you need to make sure that the browser will send it as a multipart
form(action=`/add/${store._id || ''}` method="Post" class="card" enctype="multipart/form-data")
- in
storeController.js- import Multer, which will handle the upload request, and MulterOptions which will specify where the file will be stored, and what types of files are allowed
- save the data into memory, since we don't want the original file - only the resized version
- filter by mimetype and check to see if it's an image. If yes, then continue on, if no then pass an error message
next(null, true)is a sort of callback premise in node. If you call next and you pass it something as a first value, that means it's an error. If you can next and you pass it null and a second value, that means it worked and the second value that you're passing it is the one that gets passed along.
const multer = require('multer'); const multerOptions = { storage: multer.memoryStorage(), fileFilter(req, file, next) { const isPhoto = file.mimetype.startsWith('image/'); if(isPhoto) { next(null, true); } else { next({ message: 'That filetype isn\'t allowed!' }, false); } } };
- create a controller called upload above createStore, which will be an instance of Multer passed with the multerOptions and will handle only a single field called photo
exports.upload = multer(multerOptions).single('photo');
- in
_storeForm.pug- let's add the field for the image upload above the address field
- set it accept gifs, pngs, and jpegs, so that the user can select only allowed types (but we still have the server side validation in case)
- if the store already has a photo, display it with an 200px wide thumbnail
label(for="photo") Photo input(type="file" name="photo" id="photo" accept="image/gif, image/png, image/jpeg") if store.photo img(src=`/uploads/${store.photo}`, alt=store.name width=200)
- in
storeController.js- import packages jimp and uuid, which will allow us to resize our photos and create unique identifiers for each image
const jimp = require('jimp'); const uuid = require('uuid');
- we'll add a resize method that functions as middleware right below our upload method and checks first to see if there is a file to resize
exports.resize = async (req, res, next) => { // check if there is no new file to resize if( !req.file ) { next(); // skip to the next middleware return; } };
- in
index.js- add in the storeController methods upload and resize to the route before creating the store
router.post('/add', storeController.upload, catchErrors(storeController.resize), catchErrors(storeController.createStore) );
- in
storeController.js- in the resize method, generate a unique id with the correct extension and store it in req.body
const extension = req.file.mimetype.split('/')[1]; req.body.photo = ${uuid.v4()}.${extension}
- read the file into memory, resize it to 800px width and auto height, and save it in the uploads folder. Finally call next to pass it onto the next function.
const photo = await jimp.read(req.file.buffer); await photo.resize(800, jimp.AUTO); await photo.write(`./public/uploads/${req.body.photo}`); next();
- in
models/Store.js- update the model to include photo
photo: String - add the new upload and resize methods to the route for updating each store
router.post('/add/:id', storeController.upload, catchErrors(storeController.resize), catchErrors(storeController.updateStore) );
-
in
index.js- create a route for a single store using the store slug and connect it to a controller called getStoreBySlug
router.get('/store/:slug', catchErrors(storeController.getStoreBySlug));
-
in
storeController.js- create a new controller called getStoreBySlug
- query the database to find the store based on the slug
- if there is no store, then pass it along to error handling
- render a template called store and pass in a title and store as params
exports.getStoreBySlug = async (req, res, next) { const store = await Store.findOne({ slug: req.params.slug }); if(!store) { next(); return; } res.render("store", { title: store.name, store }) }
-
create a new view
views/store.pug- display a hero with the store title and image (default to 'store.png' if no images are set)
- display an inner section with a static Google Map, the address, description, and if there are tags, display them
- for the map, there is a helper function staticMap found in
helpers.jsthat takes in an array with lng and lat values and then converts it into a usable URL for the Maps API. Notice Google Maps expects lng then lat while MongoDB expects lat then lng.
extends layout block content .single .single__hero img.single__image(src=`/uploads/${store.photo || 'store.png' }`, alt=`${store.name}`) h2.title.title--single a(href=`/stores/${store.slug}`)= store.name .single__details.inner img.single__map(src=h.staticMap(store.location.coordinates)) p.single__location= store.location.address p= store.description if store.tags ul.tags each tag in store.tags li.tag a.tag__link(href=`/tags/${tag}`) span.tag__text ##{tag}
-
currently there is no check for unique slugs when saving a new store, so you can create a store with the same name, but it will be unreachable since it will redirect to the first store
-
in
models/Store.js- async the pre save function
- create a regex that will match the slug name OR a slug name-# and store it in a variable
slugRegEx - search the database for store slugs with the regex and store it in a variable
storesWithSlug- use
this.constructorsince the Store hasn't been created yet. It will be equal to Store by the time the function runs.
- use
- if there are stores, then increment the slug by one
storeSchema.pre('save', async function(next) { if (!this.isModififed('name')) { next(); return; } this.slug = slug(this.name); const slugRegEx = new RegExp(`^(${this.slug}((-[0-9]*$)?)$)`, 'i'); const storesWithSlug = await this.constructor.find({ slug: slugRegEx }); if(storesWithSlug.length) { this.slug = `${this.slug}-${storesWithSlug.length+1}`; } next(); });
- tags page needs
- list of tags and how many stores are in that tag
- filter stores based on the tag
- to get the list of tags, we we don't want to loop through each store and find if it has tags, because that will become slow if we have a lot of stores. Instead let's offload the heavy lifting of querying to the database
- use aggregation (kind of like reduce in JavaScript) for complex queries
- in
index.js- create a route for the tags page and another route that will have the tag passed as a param. They'll use a store controller method called getStoresByTag.
router.get('/tags', catchErrors(storeController.getStoresByTag); router.get('/tags/:tag', catchErrors(storeController.getStoresByTag);
- in `storeController.js'
- create
getStoresByTagand query the database for a list of tags using a method calledgetTagsList(). This isn't a default method of Store, we'll actually create this custom method in the store model.
- create
exports.getStoresByTag = async (req, res) => {
const tags = await Store.getTagsList();
};- in
/models/Store.js- create the static method called
getTagsListon the store schema - important to use a proper function, not arrow notation, since we'll be using
thisas a proxy for the database - to calculate aggregate values on the database we use the aggregate function
- this takes in an array of objects that each function as a pipeline operator, meaning each object will modify the data in sequence. Each pipeline operator is prefixed with
$. - first we'll
$unwindthe database based on$tags- the
$unwindoperator will take the tags array in our schema, and return a list of stores that contain any of the tags in the array (should be all of the stores except those that don't have any tags). - the
$prefix on tags indicates that this is a field in the MongoDB document
- the
- then we'll
$groupthem based on the$tagsand we're also going to create a new property called count which will$sumall the stores in that tag by just adding 1 to each instance - finally we'll
$sortthe most popular stores first based on the highest count
- this takes in an array of objects that each function as a pipeline operator, meaning each object will modify the data in sequence. Each pipeline operator is prefixed with
storeSchema.statics.getTagsList = function() { return this.aggregate([ { $unwind: '$tags' }, { $group: { _id: '$tags', count: { $sum: 1 } } }, { $sort: { count: -1 } } ]); };
- create the static method called
- in
storeController.js- render a view called tag and pass in a title called 'Tags' and the tag in the params
res.render('tag', { title: 'Tags', tags });
- in a newly created
/views/tag.pug- add a title and for each tag, display the tag name and count
extends layout block content .inner h2= title ul.tags each t in tags li.tag a.tag__link(href=`/tags/${t._id}`) span.tag__text= t._id span.tag__count= t.count
- in
storeController.js- when a tag is selected, we want to rename the title to the tag name, and highlight the selected tag
- store the tag in the params in a variable and pass it to our template
const tag = req.params.tag; res.render('tag', { title: 'Tags', tags, tag })
- in
/views/tag.pug- if there is a tag selected, make the title the tag name, otherwise default to the title "Tags"
h2 #{tag || title}- add a class of
tag__link--activeonto the link if there is a tag selected
a.tag__link(href=`/tags/${t._id}` class=(t._id === tag ? 'tag__link--active' : '' ))
- in
storeController.js- in order to await multiple queries, we need to remove the await on tags and store the promise in a renamed variable called
tagsPromise- this will ultimately run the query for the store and tags simultaneously, instead of awaiting the tags first and then running stores
- query the database to filter all stores based on the tag in the params and store it in a
storesPromisevariable - await multiple promises with
Promise.all(), which takes an array of promises - deconstruct the result into tags and stores variables with ES6 bracket notation
- pass the stores variable a parameter in our views
exports.getStoresByTag = async (req, res) => { const tag = req.params.tag; const tagsPromise = Store.getTagsList(); const storesPromise = Store.find({ tags: tag }); const [tags, stores] = await Promise.all([tagsPromise, storesPromise]); res.render('tag', { title: 'Tags', tags, tag, stores }) };
- in order to await multiple queries, we need to remove the await on tags and store the promise in a renamed variable called
- in
tag.pug- add the stores into the view with our storeCard mixin
extends layout include mixins/_storeCard block content .inner h2 #{tag || title} ul.tags each t in tags li.tag a.tag__link(href=`/tags/${t._id}` class=(t._id === tag ? 'tag__link--active' : '' )) span.tag__text= t._id span.tag__count= t.count .stores each store in stores +storeCard(store)
- in
storeController.js- while each specific tag page is now rendering stores, the main tag page doesn't display anything, but we want it to display all the stores with tags
- create a new variable called
tagQueryand then if there is a tag in the params then use that otherwise just check if the store has a tag at all. Pass this to the query forstoresPromise
const tagQuery = tag || { $exists: true }; const storesPromise = Store.find({ tags: tagQuery });
- in
index.js- create a route for loging in a user with a new controller called
userControllerand methodloginForm
router.get('/login', userController.loginForm)
- create a route for loging in a user with a new controller called
- create a new controller
/controllers/userController.js- import Mongoose and connect the login form with a view called 'login' and pass it the same title
const mongoose = require('mongoose'); exports.loginForm = (req, res) => { res.render('/login', { title: 'Login' }) };
- in
index.js- import the new controller
const storeController = require('../controllers/userController');
- create a new view
/views/login.pug- add the standard layout
extends layout block content .inner
- create a new mixin
/views/mixins/_loginForm.pug- create a basic login form with email, password, and login button
mixin loginForm() form.form(action="/login" method="POST") h2 Login label(for="email") input(type="email" name="email") input(type="password" name="password") input.button(type="submit" value="Log In")
- create a new model for users
/models/User.jsto store the login data- import Mongoose, the schema, and set ES6 promises
const mongoose require('mongoose'); const Schema = mongoose.Schema; mongoose.Promise = global.Promise;
- import md5, validator, mongodbErrorhandler, and passportLocalMongoose packages
const md5 = require('md5'); const validator = require('validator'); const mongodbErrorHandler = require('mongoose-mongodb-errors'); const passportLocalMongoose = require('passport-local-mongoose');
- make the model's schema including email and name and export it
const userSchema = new Schema({ email: { type: String, unique: true, lowercase: true, trim: true validate: [validator.isEmail, 'Invalid Email Address'], required: 'Please supply an email address' }, name: { type: String, required: 'Please suppoy a name', trim: true } }); module.exports = mongoose.model('User', userSchema);
- add passport and mongodbErrorHandlers
- Passport.js takes away a lot of the heavy lifting that comes along with managing sessions or creating tokens or logging people in, logging people out.
- the error handler gives us nice error messages
userSchema.plugin(passportLocalMongoose, { usernameField: 'email' }); userSchema.plugin(mongodbErrorHandler);
- in
index.js- set up a route for registering a user
router.get('/register', userController.registerForm);
- in
userController.js- set up the registerForm method
exports.registerForm = (req, res) => { res.render('register', { title: 'Register' }); };
- in a newly created
views/register.pug- create the register form
extends layout block content .inner form.form(action="/register" method="POST") h2 Register label(for="name") Name input(type="text" name="name" required) label(for="email") Email input(type="email" name="email" required) label(for="password") Password input(type="password" name="password") label(for="password-confirm") Confirm Password input(type="password" name="password-confirm") input.button(type="sumbit" value="Register")
- in
userController.js- create a validateRegister middleware to validate the registration data
- the sanitizeBody helper method comes from
app.jsfrom the expressValidator package. It will help normalize all the different variations of emails (e.g. w.e.s.bos@gmail.com or wesbos+test@gmail.com). - check the body to see if they supplied a name, email, and password and make sure it's not empty
- these probably won't ever trip, but just in case someone has an old browser or maliciously turns off the html5 validators
- check to make sure the confirm password field matches the password
- store the validation errors in a variable called errors
- if there are errors, flash the errors with the error messages
- then render the page, make sure the body fields have the body data, and make sure you pass the flash
- return to stop the function
- if there aren't errors, then just use
next()
- the sanitizeBody helper method comes from
exports.validateRegister = (req, res, next) => { req.sanitizeBody('name'); req.checkBody('name', 'You must supply a name!').notEmpty(); req.checkBody('email', 'That Email is not valid!').notEmpty(); req.sanitizeBody('email').normalizeEmail({ remove_dots: false, remove_extension: false, gmail_remove_subaddress: false }); req.checkBody('password', 'Password Cannot be Blank!').notEmpty(); req.checkBody('password-confirm', 'Confirmed Password cannot be blank!').notEmpty(); req.checkBody('password-confirm', 'Oops! Your passwords do not match').equals(req.body.password); const errors = req.validationErrors(); if (error) { req.flash('error', errors.map(err => err.msg)); res.render('register', { title: 'Register', body: req.body, flashes: req.flash() }); return; } next(); };
- create a validateRegister middleware to validate the registration data
- in
index.js- finally set the route for posting the register form
router.post('/register', userController.validateRegister);
- in
index.js- add a new middleware to the post route for registering
router.post('/register', userController.validateRegister userControoler.register);
- in
start.js- import our User model into our application
require('./models/User');
- in
userController.js-
import the User into our controller as well as a library called Promisify
-
create a new method on exports called register
-
create a new user by passing in email and name properties whose values come from the body of our form
-
register the user by using the
register()method, which comes from the passport local mongoose package in our Users model- problem is it doesn't return a promise, it's callback based, so we'll take it and use promisify
const User = mongoose.model('User'); const promisify = require('es6-promisify'); exports.register = async (req, res, next) => { const user = new User({ email: req.body.email, name: req.body.name }); const register = promisify(User.register, User); await register(user, req.body.password); next(); };
-
- in a new controller
/controllers/authController.js- import passport
- instead of using the typical req, res for middleware, we'll be using some of the stuff provided by passport
- we use the authenticate method, and pass in a "strategy", in this case we'll be using a local strategy, and pass in a config object that specifies where to redirect and what to flash in case of success or failure
const passport = require('passport'); exports.login = passport.authenticate('local', { failureRedirect: '/login', failureFlash: 'Failed Login!', successRedirect: '/', succesFlash: 'You are now logged in!' });
- in
index.js- import the auth controller and use the middleware in the route
const authController = require('../controllers/authController'); router.post('/register', userController.validateRegister, userController.register, authController.login);
- in a newly created file
/handlers/passport.js- we need to specify the strategy for passport.js
- import passport, mongoose, and our User
- what's going to happen is we're going to log in to passport and it's going to ask what information would you like on each request? In our case, we just want to pass along the User object
const passport = require('passport'); const mongoose = require('mongoose'); const User = mongoose.model('User'); passport.use(User.createStrategy()); passport.serializeUser(User.serializeUser()); passport.deserializeUser(User.deserializeUser());
- in
app.js- require our new handler
require('./handlers/passport');
- in
authController.js- create a middleware method to logout that will log them out, flash them a message, and then redirect them to home
exports.logout = (req, res) => { req.logout(); res.flash('success', 'You are now logged out!'); res.redirect('/'); }
- in
index.js- create a route to handle logging out
router.get('/logout', authController.logout);
- we have a route for the login page, but nothing for submitting the form. So we'll use the same method that we used for registering
router.post('/login', authController.login);
- a virtual field in mongoose is essentially something that can be generated. For example if you have someone's weight in pounds, but wanted to convert to kilograms, that data could be generated.
- in
User.js- add a virtual field for the gravatar
- gravatars use a hash of the email, so we'll be using the md5 package we imported to create a hash of the users email
- we use a proper function so we can reference
this - we return a link containing the hash and specifying a size of 200px
userSchema.virtual('gravatar').get(function() { const hash = md5(this.email); return `https://gravatar.com/avatar/${hash}?s=200` });
- in
authController.js- the user shouldn't be able to add stores if their logged in, so we'll make a middleware to make sure their logged in
- we'll first check if their logged in with the passport method
isAuthenticated(), which if they are, then just pass next - if not, then we'll flash them an error message, and redirect them to the login page
exports.isLoggedIn = (req, res, next) => { if(req.isAuthenticated()) { next(); return; } req.flash('error', 'Oops you must be logged in to do that!'); res.redirect('/login'); }
- in
index.js- add the
isLoggedInmiddleware to the add route
router.get('/add', authController.isLoggedin, storeController.addStore);
- add the
- in
index.js- create a route for our user accout page and make sure their signed in first
router.get('/account', authController.isLoggedIn, userController.account);
- in
userController.js- create a method for displaying an edit account
exports.account = (req, res) => { res.render('account', { title: 'Edit Your Account' }); };
- in a newly created
views/account.pug- create the edit account template complete with an h2 title and form with
extends layout block content .inner h2= title form(action="/account" method="POST") label(for="name") Name input(type="text" name="name" value=user.name) label(for="email") Email input(type="email" name="email" value=user.email)
- in
index.js- create a route for posting to the account page that will use an update account method that we'll create. It will also use async await so we can wrap it in a catchErrors as well.
router.post('/account', catchErrors(userController.updateAccount));
- in
userController.js- create the updateAccount method
- stash the data in the body fields in a variable called updates
- stash the user by awaiting and querying the database using findOneAndUpdate, which will take three parameters
- the query, which we'll find the user through their user id
- the updates, which we'll use $set, and that will replace the values of the field
- the options, where we'll specify new to return the new user, runValidators which will run all our validators, and context, which is required by mongoose to run the query properly
- finally we'll flash them a success message and redirect them to 'back', which will just return them to the page they were previously
exports.updateAccount = async (req, res) => { const updates = { name: req.body.name, email: req.body.email } const user = await User.findOneAndUpdate( { _id: req.user._id } { $set: updates }, { new: true, runValidators: true, context: 'query' } ); req.flash('success', 'Updated the profile!'); res.redirect('back'); };
- we need to create a forgot password part of our login form
- in a newly created mixin
/views/mixins/_forgot.pug- create the form with a title and label and input for email
mixin forgotForm() form.form(action="/account/forgot" method="POST") h2 I forgot my password! label(for="email") Email input(type="email" name="email") input.button(type="submit" value="Send a Reset")
- in
login.pug- add our new forgot form below the login form
extends layout include mixins/_loginForm include mixins/_forgot block content .inner +loginForm() +forgotForm()
- in
index.js- add a route to handle the password reset
router.post('/account/forgot', catchErrors(authController.forgot));
- in
authController.js- add a new method called forgot
- first we check if the user has an email address on file. If not, then we flash them an error. Sometimes it's a good idea not to let the user know if they have an account based on their email address (just in case someone is trying to be malicious), so you can use a generic message that just says a password reset has been set to that account.
- Then we set the reset tokens and expiry on their account
- there's a module built into Node to generate cryptographically secure random strings, so we'll require "crypto" at the top
- we'll use method on crypto called randomBytes to generate a hex string, and we'll also set a password expire date by adding an hour to the current time
- we also need to require mongoose and our model
- in
User.jswe need to add fields to our users for the token and expiry
resetPasswordToken: String, resetPasswordExpires: Date
- then we'll just
await user.save();
- then we will send them an email with the token (but for now since we dont' have email set up, we'll just flash them a link)
- finally we redirect them to the login page
const crypto = require('crypto'); const mongoose = require('mongoose'); const User = mongoose.model('User'); exports.forgot = async (req, res) => { const user = await User.findOne({ email: req.body.email }); if (!user) { req.flash('error', 'A password reset has been mailed to this email'); return res.redirect('/login'); } user.resetPasswordToken = crypto.randomBytes(20).toString('hex'); user.resetPasswordExpires = Date.now() + 360000; await user.save(); const resetURL = `http://${req.headers.host}/account/reset/${user.resetPasswordToken}`; req.flash('success', `You have been emailed a password reset link. ${resetURL}`); res.redirect('/login'); };
- in
index.js- create a route for a reset token
router.get('/account/reset/:token', catchErrors(authController.reset));
- in
authController.js- create the method on our controller to handle the reset
- find the user based on their reset token and make sure the token hasn't expired
- we can do a cool query using
$gtto check if the current time is greater than the expiration date
- we can do a cool query using
- if the query fails, then flash an error and redirect them to the login page
- otherwise render a reset page
exports.reset = async (req, res) => { const user = await User.findOne({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } }); if (!user) { req.flash('error', 'Password reset is invalid or has expired'); return res.redirect('/login'); } res.render('reset', { title: 'Reset your Password' }); };
- in a new
views/reset.pug- create a reset password form
extends layout block content .inner form.form(method="POST") h2= title label(for="password") Password input(type="password" name="password") label(for="password-confirm") Password Confirm input(type="password" name="password-confirm") input.button(type="submit" value="Reset Password")
- in
index.js- create a route for posting the reset form
- we'll need to create some middleware to confirm and validate the password, and then finally we'll update the account's password
router.post('/account/reset/:token', authController.confirmedPasswords, catchErrors(authController.update ));
- in
authController.js- create a new method to confirm the password and password-confirm are the same
- access a property with a dash by using quotes and brackets
- if it matches, then pass next
- if not, flash them an error and redirect them back
exports.confirmPasswords = (req, res) => { if (req.body.password === req.body['password-confirm']) { next(); return; } req.flash('error', 'Passwords do not match!'); res.redirect('back'); };
- create a new method to update the password
- we want to find the user and use the same query to check the token and expiry, since they could have arrived on the page, but just left it open
- then we want to update the password
- setPassword() is given to us from the plugin passport-local-mongoose that we imported in our model
Users.js - but it uses callbacks and not promises, so we'll use promisify and use that method and bind it on the user
- remember to import promisify at the top of the document
- setPassword() is given to us from the plugin passport-local-mongoose that we imported in our model
- then we'll await the promise and pass in the new password in the body
- next we'll clear the reset token and expiries by setting them to undefined
- now we actually want to save the user using
save()from passport and store that promise in an updatedUser variable - then we'll await that promise and login using
login(), also from passport - finally we'll flash them a success message and redirect them home
const promisify = require('es6-promisify'); exports.update = async (req, res) => { const user = User.findOne({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } }); if (!user) { req.flash('error', 'Password reset is invalid or has expired'); return res.redirect('/login'); } const setPassword = promisify(user.setPassword, user); await setPassword(req.body.password); user.resetPasswordToken = undefined; user.resetPasswordExpires = undefined; const updatedUser = await user.save(); await req.login(updatedUser); req.flash('Success', 'Nice! Your password has been reset! You are now logged in!'); res.redirect('/'); }
- use Mailtrap to fake a mail server. So instead of actually sending email to your users, it will just store it in Mailtrap, where you'll be able to quickly see who the emails are being sent to, what they look like, when they've been sent.
- in
variables.env- change the username and password to the mailtrap credentials listed for SMTP
- in a new
handlers/mail.jsfile- import nodemailer, pug, juice, htmlToText, and promisify packages
const nodemailer = require('nodemailer'); const pug = require('pug'); const juice = require('juice'); const htmlToText = require('html-to-text'); const promisify = require('es6-promisify');
- we have nodemailer and we need to create what's called a transport, which is just different ways of sending email, SMTP being the most common. we'll use the
createTransport() method provided by nodemailer and pass in a config object which contains fields for host, port, and auth
const transport = nodemailer.createTransport({ host: process.env.MAIL_HOST, port: process.env.MAIL_PORT, auth: { user: process.env.MAIL_USER, pass: process.env.MAIL_PASS } });
- when someone asks for a password reset, we're going to make a method called send, which will be asynchronous and take in some options (which will be passed in from the forgot method in the authController).
- then we'll create a mailOptions object with fields for from, to, subject, html, and text fields
- 'from' is whatever email you'd like to send it from
- 'to' will be the email from the user passed in through options
- html and text will be filled out later
- next we'll use promisify for the
sendMail()method on transport, and bind it to transport - finally we'll return the sendMail variable using the mailOptions
exports.send = async (options) => { const mailOptions = { from: `Koa Kekuna <kekoaponolani@gmail.com>` to: options.user.email html: 'This will be filled in later' text: 'This will also be filled in later' } const sendMail = promisify(transport.sendMail, transport); return sendMail(mailOptions); };
- in
authController.js- import the mail library
const mail = require('../handlers/mail');
- then in the forgot method, use the send method from mail right after we generate the resetURL and before we flash them a success message and redirect them to the login page
- we'll pass in those options now with a config object which will include the user, subject, resetURL, and filename
- the filename will be used when we try to render out the HTML and look for a document called password-reset.pug
await mail.send({ user, subject: 'Password Reset', resetURL, filename: 'password-reset' })
- in
mail.js- we'll take care of the text and html values in our mailOptions variable by using the packages we imported
- first we'll create a function called generateHTML and that will take in two things, the filename and options (which will default to an empty object)
- we don't use
exports.generateHTMLbecause this function not needed anywhere else outside the file, so we just use a regularconst
- we don't use
- then we create a variable called html, and we use the pug library to use the
renderFile(), which will take name of the file we're looking for- whenever you pass a function reference to something on your disk, you don't actually know where you are in the folder system, because of course we're in a handlers folder. But this renderFile folder, it's in a totally different folder. And it gets a little bit mixed up. So what we can use is a special variable
__dirname, which is available to us in any file.
- whenever you pass a function reference to something on your disk, you don't actually know where you are in the folder system, because of course we're in a handlers folder. But this renderFile folder, it's in a totally different folder. And it gets a little bit mixed up. So what we can use is a special variable
- next we need to inline our CSS into our html by using the juice library;
const generateHTML = (filename, options = {}) => { const html = pug.renderFile(`${__dirname}/../views/email/${filename}.pug`, options); const inlined = juice(html); return inlined; };
- then in our send method we create html and text variables
- html will be generated using our generateHTML method
- text will be genereated by the htmlToText library
exports.send = async (options) { const html = generateHTML(options.filename, options); const text = htmlToText.fromString(html); const mailOptions = { to: `Koa Kekuna <kekoaponolani@gmail.com>`, from: options.user.mail, subject: options.subject, html, text } const sendMail = promisifiy(transport.sendMail, transport); return sendMail(mailOptions); }
- we want to be able to set an author property on our stores that is linked to one of the users in our database
- in
Store.js- add a new author field to the schema. the type is going to be an Object Id, which is the unique id given to each user. ref is how we reference the desired model. finally we'll make it required and have an error message.
author: { type: mongoose.Schema.ObjectId, ref: 'User', required: 'You must supply an author' }
- in
storeController.js- in our createStore method, we're going to set the author on the
req.bodyto be the id from the user that is creating the store.
exports.createStore = async (req, res) => { req.body.author = req.user._id; const store = await (new Store(req.body)).save(); req.flash('success', `Successfully created ${store.name}. Care to leave a review?`); res.redirect(`/store/${store.slug}`); };
- in our getStoreBySlug method, we'll use the
.populate()method to have the database return not only the user's user_id, but all fields for the user
exports.getStoreBySlug = async (req, res, next) => { const store = await Store.findOne({ slug: req.params.slug }).populate('author'); };
- in our editStore method, we need to stop users from editing stores that they did not create. Kake a helper function called confirmOwner that will take in a store and user, and if the store's author does not equal the stores user, throw an error
.equals()is a method to compare Object Id's
const confirmOwner = (store, user) => { if (!store.author.equals(user._id)) { throw Error('You must own a store in order to edit it!'); } } exports.editStore = async(req, res) => { const store = await Store.findOne({ _id: req.params.id }); confirmOwner(store, req.user); res.render('editStore', { title: `Edit ${store.name}`, store }); };
- in our createStore method, we're going to set the author on the
- in
_storeCard.pug- we need to selectively show the pencil edit icon for the store based on if the user can edit them or not
- since there may be people not logged in, and therefore no user, an error will be thrown, so we must check if there is a user first
.store__actions if user && store.author.equals(user._id) .store__action.store__action--edit a(href=`/stores/${store._id}/edit`) != h.icon('pencil')
- in
package.json- we have a script called "sample", which will run
./data/load-sample-data.js - we also have script called "blowitallaway", which will delete all data
- we have a script called "sample", which will run
- in
load-sample-data.json- not actually part of our application, so have to manually import parts
- it will read the dummy data (e.g. stores.json), store it, and then create objects
- comment out the Reviews parts, since we don't have that functionality yet
- in the terminal
- run
npm run sampleto generate the sample data - 16 stores with 3 authors (and eventually 41 reviews)
- run
- look in the Readme for the email and passwords of the authors
- indexes support efficient execution of queries. MongoDB will sort of pre-scan the contents, making it MUCH faster at completing queries. So if in your application, you know you're going to be querying something often, it's worth it to index it.
- we already have
_idindexed by default, and in our users model, we have email indexed as well, which most likely a plugin indexed for us - in
models/Store.js- we want to index the name and description of the store, so we can easily search for it
- we also specify how we would like it to be indexed as - in our case text
storeSchema.index({ name: 'text', description: 'text' });
- in
index.js- make a new section for dealing with our API
- create a new route to
/api/searchwith a method on our store controller called searchStores
router.get('/api/search', catchErrors(storeController.searchStores));
- in
storeController.js- create the async method searchStores
req.querywill return all the queries being passed along via the URL, and we're looking for what's stored in q -?q=store- we want to search through all the stores using
Store.find()and await the results - to filter the stores, we'll use a MongoDB text operator
$text, which will perform a text on any fields indexed with a text index- there are several options we pass through, but we'll want to include
$search, to pass in our queryreq.query.q
- there are several options we pass through, but we'll want to include
- we also want to sort our results, based on how relevant the results are, through a field called "score"
- we'll add a second argument to
Store.find(), which will be an object, and we're going to tell it to project (which in MongoDB means add a field) a "score". The score is going to made up of the metadata via an operator$meta, and the only metadata currently in MongoDB is "textScore". - to actually sort the results, we'll chain a
.sort()ontoStore.find(), and use the same parameters as before - finally we'll limit the results to the top 5 results
exports.searchStores = async (req, res) => { const stores = await Store // first find stores that match .find({ $text: { $search: req.query.q } }, { score: { $meta: 'textScore' } }) // then sort them .sort({ score: { $meta: 'textScore' } }) // limit to only 5 results .limit(5); res.json(stores); };
- if we inspect the HTML search bar, we have an input with a class of
search__inputand a hidden div with a class ofsearch_results - in a new file
public/javascripts/modules/typeAhead.js- import a library called axios
- create a function called typeAhead, which will take in the search box, and will be exported from the file
const axios = require('axios'); function typeAhead(search) { console.log(search); } export default typeAhead;
- in
public/javascripts/delicious-app.js- import typeAhead
- run the function and pass it the contents of the search box
import typeAhead from './modules/typeAhead' typeAhead( $(.search) );
- in
typeAhead.js- essentially how this is going to work is we are going to listen for when somebody types into the search box, then we're going to hit our API endpoint with the value that's typed into that box, and then wait for the results to come back, which we'll show in a dropdown menu.
- first we want to make sure that if the searchbox is not on the page, we don't want to run the function at all
- then we need to get the input and the results and store them
- next we need to listen for an input event on the searchbox (
.onis bling.js shortcut for addEventListener) - if there is no value in the input, we should hide the search results and stop the function
- otherwise we should display the search results
- make sure the search results innerHTML is blank if the search doesn't find anything (e.g. we initially find some results, but change our mind and backspace, then the results should disappear)
- we'll use axios to hit our endpoint
- we use the
.get()axios method and specify the url for our endpoint - then we chain a
.then()to return a response
- we use the
function typeAhead(search) { if (!search) return; const searchInput = document.querySelector('input[name="search"]'); const searchResults = document.querySelector('.search__results'); searchInput.on('input' function() { // if there is no value, quit it! if (!this.value) { searchResults.style.display = 'none'; return; // stop! } // show the search results~ searchResults.style.display = 'block'; searchResults.innerHTML = ''; axios .get(`/api/search?q=${this.value}`) .then(res => { console.log(res.data); }); }); }
- we'll make a new function called searchResultsHTML, which will take in an array of stores and return us some HTML
- we'll map over the stores, return some HTML with a link to the store, and the store name
- we'll join the array, since we just want one big string of HTML, not an array of strings
function searchResultsHTML(stores) { return stores.map(store => { return ` <a href="/stores/${store.slug}" class="search__result"> <strong>${store.name}</strong> </a> ` }).join(''); }
- in our call to axios, we set the innerHTML of the search results to be the results of our new searchResultsHTML function
- however if a user types something and nothing is found, we also notify them with a message
- we'll also stop XSS attacks with domPurify
- finally we catch any errors and send it to an error tracking software like Sentry
import dompurify from 'dompurify'; axios .get(`/api/search?q=${this.value}`) .then(res => { if { searchResults.innerHTML = dompurify.sanitize(searchResultsHTML(res.data)); return; } else { searchResults.innerHTML = dompurify.sanitize(`<div class="search__result">No results for ${this.value} found!</div>`); } }) .catch(err => { console.log(err); });
- how to handle keyboard inputs
- first we check if the user isn't pressing on our target keys (up arrow, down arrow, or enter), then we simply just return
- then we need to add the basic functionality to each target key
- if a user presses up, it should move the active item up (or cycle to last item if it's the first)
- if a user presses down, it should move the active item down (or cycle to the first time if it's the last)
- if a user presses enter, it should open a link to that store
- search for a result with a class
search__result--active, which we'll define ascurrent - search for all results with a class
.search__result, which we'll define as items - define
next, which we'll use to establish the next active item - if the user presses down and there's already an active item, then the next active item will be the next sibling OR if there is no next sibling (it's the last item), it should move to the first item
- if the user presses down and there's no active item, then the next active item will be the first item
- if the user presses up and there's already and active item, then the next active item will be the previous sibling OR if there is no next sibling (it's the first item), it should move to the last item
- if the user presses enter, it should activate the link on the current item
- finally we remove the activeClass from the current item and add it to the next item
searchInput.on('keyup', (e) => { if (![38, 40, 13].include(e.keyCode)) { return; } const activeClass = 'search__result--active'; const current = search.querySelector(`.${activeClass}`); const items = search.querySelectorAll('.search__result'); let next; if (e.keyCode === 40 && current) { next = current.nextSiblingElement || items[0]; } else if (e.keyCode === 40) { next = items[0]; } else if (e.keyCode === 38 && current) { next = current.previousSiblingElement || items[items.length - 1]; } else if (e.keyCode === 38) { next = items[items.length - 1]; } else if (e.keyCode === 13) { window.location = current.href; } current.classList.remove(activeClass); next.classList.add(activeClass); });
- we're gonna create a store locator with Google Maps built in. We'll ping a URL given a lat and long, and that will return to us the top 10 closest stores that are within a 100km.
- we'll need to index our data, create a route, and handile it our store controller.
- in
models/Store.js- we'll index our location by storing it as geospatial data, which enables quick searches based on the lat and long
- first call
.index()on ourstoreSchemaand pass it an object- specify the location is a '2dsphere'
storeSchema.index({ location: '2dsphere' });
- in
index.js- create a route with the url
/api/stores/near, catch the errors, and we'll create amapStoresmethod on our controller
router.get('/api/stores/near', catchErrors(storeController.mapStores));
- create a route with the url
- in
storeController.js- create the method
mapStoreswhere we'll query the database with the lat and lng from the URL - first store the location data in an array called coordinates
- remember the MongoDB positioning of [lng, lat]
- also convert from strings to floats
- next we'll make our query in a separate object
q- we want to search the stores where the location property is near using the MongoDB operator
$near - we'll use
$geometryto specify the type and coordinates - we'll also specify the max distance in kilometers with
$maxDistance
- we want to search the stores where the location property is near using the MongoDB operator
- finally we'll await our query and return the results
- use
select()method to specify the data we'd like to return andlimit()to limit the results
exports.mapStore = async (req, res) { const coordinates = [req.query.lng, req.query.lat].map(parseFloat); const q = { location: { $near: { $geometry: { type: 'Point', coordinates }, $maxDistance: 10000 } } }; const stores = await Store.find(q).select('slug name description location').limit(10); res.json(stores); };
- create the method
-
in
index.js- create a route for a map page as well as a method on our storeController
router.get('/map', storeController.mapPage);
-
in
storeController.js- create the method
mapPage, which will render a view called map
exports.mapPage = (req, res) => { res.render('map', { title: 'Map' }); };
- create the method
-
create
views/map.pug- we'll have an input form with a name of "geolocate"
extends layout block content .inner h2= title .map .autocomplete input.autocomplete__input(type="text" placeholder="Search for Anything" name="geolocate") #map p Loading Map...
-
create
public/modules/map.js- import axios and bling.js
import axios from 'axios'; import { $ } from './bling';
- create a new function
makeMap()that takes in a 'mapDiv' - if there isn't a mapDiv on the current page, then just return the function
- create a mapOptions, specifying the lng, lat, and zoom
- create a new map using the Google Maps api (whose scripts are already loaded in layout.pug)
- pass in where it should go (mapDiv) as well as some options (mapOptions)
- call a function loadMaps function, which we'll create, and pass in our map
- create a variable input, which will store the input element
- enable autocomplete by creating an autocomplete variable, which will be passed in the input from above
const mapOptions = { center: { lat: 43.2, lng: -79.8 }, zoom: 2 } function makeMap(mapDiv) { if(!mapDiv) return; // make our map const map = new google.maps.Map(mapDiv, mapOptions); loadPlaces(map); const input = $('[name="geolocate"]'); const autocomplete = new google.maps.places.Autocomplete(input); } export default makeMap;
- create a new function
loadPlaces()that takes in a map, lat, and lng - use axios to hit our api with lat and lng as query params
- then store the response in a variable called places
- check if there are places in the first place, and flash an error if not
- then create the map markers by using
map()on places- store the lng and lat we queried from the database (remember the database sends it as [lng, lat])
- store the position as an object (with the lat and lng now switched)
- create each marker on our map by passing in our map element and position
- store the place data (which will give us access to data like store name, description, etc) onto each marker so we'll have access to it later, and then finally return the marker
- instead of guessing the zoom level, we can use something called bounds. Then we can extend the bounds for each marker's position.
- finally we can set the map center to be the center of the bounds, and then fit it (zoom in).
- when a user clicks a marker, we want an info window to appear and display the place's name
function loadPlaces(map, lat = 43.2, lng = -79.8) { axios.get(`/api/stores/near?lat=${lat}&lng=${lng}`) .then( res => { const places = res.data; if (!places.length) { req.flash('error', `Could not find any stores with a lat of ${lat}? and a lng of ${lng}`); return; } // create a bounds const bounds = new google.maps.LatLngBounds(); const infoWindow = new google.maps.InfoWindow(); const markers = places.map(place => { const [placeLng, placeLat] = place.location.coordinates; const position = { lat: placeLat, lng: placeLng }; bounds.extend(position); const marker = new google.maps.Marker({ map, position }); marker.place = place; return marker; }); markers.forEach(marker => marker.addListener('click', function() { const html = ` <div class="popup"> <a href="/stores/${this.place.slug}"> <img src="/uploads/${this.place.photo || 'store.png'}" alt="${this.place.name}" /> <p>${this.place.name} - ${this.place.location.address}</p> </a> </div> `; infoWindow.setContent(html); infoWindow.open(map, this); })); // then zoon the map to fit all the markers perfectly map.setCenter(bounds.getCenter()); map.fitBounds(bounds); }); }
- in our makeMap function, we add an event listener to the autocomplete, and then reload the map with the correct lat and lng of the users selection
function makeMap(mapDiv) { if (!mapDiv) return; const map = new google.maps.Map(mapDiv, mapOptions); loadPlaces(map); const input = $('[name="geolocate"]'); const autocomplete = new google.maps.places.Autocomplete(input); autocomplete.addListener('place_changed', () => { const place = autocomplete.getPlace(); loadPlaces(map, place.geometry.location.lat(), place.geometry.location.lng()); }); }
-
in
delicious-app.js- import
makeMapand call it while passing in our#mapdiv
- import
-
in
storeController.js- add 'photo' to the list of data to request from the database
const stores = await Store.find(q).select('slug name description location photo').limit(10);
