Part 1 was all about the look and behavior of the promotional popup in the browser. Essentially, it's what I'd made before my client asked for create and edit privileges. In part 2, we're going to set up a local MongoDB database and configure it so that we can create, read, update, and delete records.

Get ready to sweat. 😅

Building out the admin

First, we'll set up the database. Then we'll create the model, so that the database knows what data to expect. After that, we'll establish routing. Finally, in our controller, we'll define the route endpoints, or how our app should respond to a particular route (create, update, etc.).

The database

I like to think of the database in 3 parts:

  1. MongoDB itself
  2. Mongoose
  3. MongoDB Compass

MongoDB

Here are the instructions on setting up MongoDB:

Mongoose

In our Express app, we don't work directly with MongoDB. We interact with it through a tool called Mongoose. Mongoose is installed through the command line.

  npm install mongoose

Tip: 😉 As of npm versions 5 and greater, it's no longer necessary to use the --save flag, since saving is now automatic.

The model

Mongoose is happy to talk to MongoDB for us, but Mongoose first needs us to follow some rules. We need to define exactly what data is expected. We need to answer questions such as:

  • What is a required field?
  • What is an optional field?
  • What are the data types?

From the root of your Express project, add a models directory and add promo.js to it.

  // The Promo model

var mongoose = require('mongoose'),
    Schema = mongoose.Schema,
    ObjectId = Schema.ObjectId;

var promoSchema = new Schema({
    date: {type: Date, default: Date.now},
    title: { type: String, required: true, unique: true },
    text: { type: String, required: true },
    live: { type: Boolean, default: false }
});

var Promo = mongoose.model('Promo', promoSchema);

module.exports = Promo;

Let's break it down. We instantiate a new Schema, and call it promoSchema. Every promo will have an auto-generated date, a unique title, some amount of description text (which will show up on our full promotion page), and an auto-generated live value (defaulting to false). We then call the model method and pass it our newly defined schema. Finally, we export Promo, so it is available to any file importing our model.

MongoDB Compass

Are you a visual learner? I totally am, and this is an awesome tool to visualize the contents of your database without having to enter the mongo shell. You can perform all data operations directly in the application itself. Download it here.

Create a new database:

Connect to the database in your app.js

  // connect to Mongo when the app initializes
if (app.get('env') === 'development') {
  mongoose.connect('mongodb://localhost/my-db', { useMongoClient: true });
} else if (app.get('env') === 'production')  {
  // connect to production database
}

Update (11/3/17) — I noticed a deprecation warning when using mongoose.connect(). To remove the warning, I added { useMongoClient: true } as the second argument to the connect method. More info here.

After saving and restarting your app, you should hopefully see no errors in the console. If there are errors, you may not have Mongo configured correctly.

If you're not sure where to host your production database, check out mLab. At the time of this article, they're offering 500MB for the free plan. Not too shabby!

Once you sign up, they'll provide the connection string you'll need for your app. Something to note is that I haven't password protected my local database instance. If your data is sensitive, you may want to add that layer of protection locally. You'll definitely want to use a username and password in production.

Take a break

If you're feeling overwhelmed with the various setup steps, don't beat yourself up (like I once did). Setup is usually the hardest part of a brand new project and it does get easier after this. Still, this might be an ideal moment to refresh your palate and do a few loops around the neighborhood.

Routing

With the database set up, it's time to establish our routes. What structure should we use? How many do we need? I could just create another anonymous looking route in our routes file. It might look like this:

  router.get('/promo', (req, res, next) => {
  res.render('promo', {
    title: 'Company promotions'
  });
});

Then we could make another one for updating a promotion:

  router.post('/promo/:id/update', (req, res, next) => {
  res.render('promo', {
    title: 'Company promotions'
  });
});

Instead, let's be more organized and concise. In our app.js, we will define what our API will look like. This step is important to get right so that our code remains readable. Also, consider that we may not be the only developer who ever touches this code.

  app.post('/promo', api.post);              // create new promotion
app.post('/promo/:id/update', api.update); // update promotion
app.get('/promo/:id/delete', api.delete);  // delete promotion
app.post('/promo/set_live', api.set_live); // set live promotion
app.get('/promo', api.list);               // list all promotions

Now those are some readable looking routes. But what is this api. prefix? We need to define those in our controller.

Add this line right above the routes we just defined.

  const api = require('./controllers/api.js');

Lastly, create a controllers directory and add api.js.

The controller

Remember the model we created above? Let's import that into our controller on line 1 of api.js.

  var Promo = require('../models/promo.js');

[C]RUD — Create

Now let's respond to a route. Since our promos collection is empty, let's handle the route responsible for creating new promotions.

  exports.post = (req, res) => {
  var p = new Promo({
    title: req.body.title,
    text: req.body.text
  });

  p.save(err => {
    if (err) throw err;

    res.redirect('promo');
  });
}

The above expects two posted fields: title and text. In the views directory, create promo.pug. If you're using a different view engine, adjust the file extension.

Here is the standard form we'll use to post the data:

  form(method='POST' action='/promo')
  label(for='title') Promotion title
  input(type='text' name='title' id='title' required placeholder='My Awesome Promotion')
  label(for='text') Promotion text
  textarea(name='text' id='text' required)
  input(type='submit' class='button' value='Submit')

Test it

Does it work? After submitting the form, you should see your record appear in the promos collection. This is a good time to open MongoDB Compass and verify that the record made it in. You may need to hit the refresh button to see the new record.

C[R][U][D] — Read/Update/Delete

Now that we can create promotions, let's have them appear on the /promo page by handling that route. Asking for all the records is easy. We simply find all the promos and send them back to our view.

  exports.list = (req, res) => {
  var msg = req.session.msg || undefined;
  req.session.msg = null;

  Promo.find({}, (err, promos) => {
    res.render('promo', {
      promos
    });
  });
}

Tip: 😉 A nice ES6 feature is that, if an object's key and value match, there's no need to repeat ourselves. The following would also work, but is more verbose.

  res.render('promo', {
  promos: promos
});

Now that we're returning a promos object to the view, we should see records appearing after a bit of markup. The cool thing is that we now have all we need to complete the read, update and delete UI parts. Have a look at this:

  if promos.length

  each promo in promos
    form(method='POST' action='/promo/#{promo._id}/update')
      button.trigger!=promo.title
      .contents
        ul.promos
          li
            span.label Promotion Title
            input(type='text' name='update_title_#{promo._id}' value!=promo.title required)
          li
            span.label Promotion text
            textarea(name='update_text_#{promo._id}' required)=promo.text
          li
            input.button(type='submit' value='Update')
            a.delete(href='/promo/#{promo._id}/delete') Delete

else
  p There are no promotions so far.

We begin by looping through the promos object and surround each one in a form whose action attribute points to the update route we've already written. Here is that update route, in case you forgot:

  app.post('/promo/:id/update', api.update);

The action attribute is created to match the route:

  action='/promo/#{promo._id}/update'

For each form/record, when the submit button is pressed, that record will be updated.

Similarly, each delete button links to the delete route:

  app.get('/promo/:id/delete', api.delete);

  a.delete(href='/promo/#{promo._id}/delete') Delete

This is a lot of markup to visualize in your head. Here's what it looks like:

To this point, I haven't included any CSS, because I felt it would make these articles unnecessarily long. However, you might be wondering what the code looks like to toggle each update/delete form.

There are two parts and a miniscule amount of JavaScript. Notice above how each form has a button.trigger and an adjacent .contents. Here are the initial styles for .contents:

  form .contents {
  visibility hidden;
  opacity: 0;
  transition: 0.5s opacity, visibility, max-height ease-in-out, padding;
  max-height: 0;
  overflow: hidden;
}

So it starts out hidden. When the button adjacent to it has a class of active, there are styles waiting to un-hide .contents. When the active class is removed, .contents is once again hidden.

  .trigger {
  ... 

  &.active + .contents {
    visibility visible;
    max-height: 50em;
    opacity: 1;
    padding-top: 20px;
  }
}

Using the adjacent sibling combinator, all we have to do is toggle a single active class on the button to make the whole thing work.

  var btns = document.getElementsByClassName('trigger');
Array.prototype.forEach.call(btns, function(btn) {
  btn.addEventListener('click', function(e) {
    e.preventDefault();
    this.classList.toggle('active');
  });
});

You may have noticed that I am not using the fat arrow syntax (=>) here. The reason is that the following JavaScript lives on the client-side, unlike everything else we've done to this point, and IE11 doesn't support arrow functions. For maximum compatibility, I would have to transpile my code with Babel, and that layer of complexity was not worth the effort.

Our update and delete UI is ready. Now we have to finish the route endpoints for these parts.

Handling updates

Our form action (defined above) posts the id we need. Using this id, we set the promotion's title and text. This is how update works. You retrieve the object you want update, update it, and then call the save method on the object to make the whole thing official. After the save operation, we redirect back to the previous view.

  exports.update = (req, res) => {
  var id = req.params.id;

  Promo.findById(id, (err, promo) => {
    if (err) throw err;

    promo.title = req.body[`update_title_${id}`];
    promo.text = req.body[`update_text_${id}`];

    promo.save(err => {
      if (err) throw err;

      res.redirect('back');
    });
  });
}

Handling deletions

Delete is the easiest route for us handle. We remove the record and redirect back to the previous view.

  exports.delete = (req, res) => {
  Promo.deleteOne({
    _id: req.params.id
  }, err => {
    if (err) throw err;

    res.redirect('back');
  });
}

End of Part 2

Let's stop here. I refuse to detonate your beautiful brains! Part 2 was really long, right? I tried to keep it as short as possible, but this stuff takes a while to describe. In part 3, we have one more thing to deal with regarding our API routes: setting a promotion live and disabling a live promotion. Why didn't I add that here in part 2 with the other route code? I could tell you now, but that would ruin the fun.

Authentication is the other reason part 3 will soon be a thing. If logged in—cool—display the authenticated view to the user. If not, display the unauthorized view containing a link to the login page. We can't have random interlopers updating our database.

Stay tuned!


2,870 0 6