Objectives

Enable Songs and Playlists to be added via simple forms.

Exercise Solutions

This lab requires that the playlist-2 lab be completed. If you have lost your solution, create a new project in Glitch by cloning this repo:

Instructions on how to do this are here. Remember to use the url above.

These are the exercises from the last lab:

Exercise 1: UX Enhancements & Exercise 3: listplaylists partial

Introduce a 'Delete Playlist' button for each playlist, represented by a trash icon.

First, create a new partial:

views/partials/listplaylists.hbs

{{#each playlists}}
  <section class="ui stacked segment">
    <h2 class="ui header">
      {{title}}
    </h2>
    <p> Total Duration: {{duration}} </p>
    <a href="/playlist/{{id}}" class="ui icon button">
      <i class="icon folder open"></i>
    </a>
    <a href="/dashboard/deleteplaylist/{{id}}" class="ui icon button">
      <i class="icon trash"></i>
    </a>
  </section>
{{/each}}

Now we can refactor the dashboard partial to use this:

views/dashboard.hbs

{{> menu id="dashboard"}}

<section class="ui segment">
  {{> listplaylists}}
</section>

The UI should look like this:

However, the delete (trash) button doesn't work yet (try it).

Exercise 2: Delete Playlist Functionality

Make the button actually delete the denoted playlist.

First, a route to delete the playlist:

routes.js

...
router.get('/dashboard/deleteplaylist/:id', dashboard.deletePlaylist);
...

Then the method to implement this route:

controllers/dashboard.js

...
  deletePlaylist(request, response) {
    const playlistId = request.params.id;
    logger.debug(`Deleting Playlist ${playlistId}`);
    playlistStore.removePlaylist(playlistId);
    response.redirect('/dashboard');
  },
...

When introducing the new method above, you will need to separate the method from the existing index method with a comma - an syntax error will appear of you forget this. Here is the complete dashboard.js for reference:

"use strict";

const logger = require("../utils/logger");
const playlistStore = require("../models/playlist-store");

const playlist = {
  index(request, response) {
    const playlistId = request.params.id;
    logger.debug("Playlist id = ", playlistId);
    const viewData = {
      title: "Playlist",
      playlist: playlistStore.getPlaylist(playlistId)
    };
    response.render("playlist", viewData);
  },

  deleteSong(request, response) {
    const playlistId = request.params.id;
    const songId = request.params.songid;
    logger.debug(`Deleting Song ${songId} from Playlist ${playlistId}`);
    playlistStore.removeSong(playlistId, songId);
    response.redirect("/playlist/" + playlistId);
  }
};

module.exports = playlist;

Finally, we need a new method on playlist-store to delete the playlist form the model:

models/playlist-store.js

  removePlaylist(id) {
    _.remove(this.playlistCollection, { id: id });
  },

Again, a comma may be needed. This is the complete playlist-store at this stage:

"use strict";

const _ = require("lodash");

const playlistStore = {
  playlistCollection: require("./playlist-store.json").playlistCollection,

  getAllPlaylists() {
    return this.playlistCollection;
  },

  getPlaylist(id) {
    return _.find(this.playlistCollection, { id: id });
  },

  removeSong(id, songId) {
    const playlist = this.getPlaylist(id);
    _.remove(playlist.songs, { id: songId });
  },

  removePlaylist(id) {
    _.remove(this.playlistCollection, { id: id });
  },
};

module.exports = playlistStore;

Verify now that you can delete playlists.

Adding a Song

Before adding a song, lets align the delete button appearance with the delete playlist. Use this icon button instead of the existing one:

views/partials/listsongs.hbs

          <a href="/playlist/{{../playlist.id}}/deletesong/{{id}}" class="ui icon button">
            <i class="icon trash"></i>
          </a>

This is the complete listsongs partial now:

<table class="ui table">
  <thead>
    <tr>
      <th>Song</th>
      <th>Artist</th>
      <th></th>
    </tr>
  </thead>
  <tbody>
    {{#each playlist.songs}}
      <tr>
        <td>
          {{title}}
        </td>
        <td>
          {{artist}}
        </td>
        <td>
          <a href="/playlist/{{../playlist.id}}/deletesong/{{id}}" class="ui icon button">
            <i class="icon trash"></i>
          </a>
        </td>
      </tr>
    {{/each}}
  </tbody>
</table>

In order to add songs, we need this new partial which provides a simple add song form. Introduce this new partial now:

views/partials/addsong.hbs

<form class="ui stacked segment form" action="/playlist/{{playlist.id}}/addsong" method="POST">
  <div class="two fields">
    <div class="field">
      <label>Title</label>
      <input placeholder="Title" type="text" name="title">
    </div>
    <div class="field">
      <label>Artist</label>
      <input placeholder="Artist" type="text" name="artist">
    </div>
  </div>
  <button class="ui blue submit button">Add Song</button>
</form>

We need to incorporate this in the playlist view:

views/playlist.hbs

{{> menu}}

<section class="ui center aligned middle aligned segment">
  <h2 class="ui header">
    {{playlist.title}}
  </h2>
  {{> listsongs}}
  {{> addsong}}
</section>

This should now appear at then end of each playlist:

Now we meed a new route to support the form we have just added:

routes.js

...
router.post('/playlist/:id/addsong', playlist.addSong);
...

... and this new function in the playlist controller:

controllers/playlist.js

...
  addSong(request, response) {
    const playlistId = request.params.id;
    const playlist = playlistStore.getPlaylist(playlistId);
    const newSong = {
      title: request.body.title,
      artist: request.body.artist,
    };
    playlistStore.addSong(playlistId, newSong);
    response.redirect('/playlist/' + playlistId);
  },
...

This is the complete playlist controller now:

"use strict";

const logger = require("../utils/logger");
const playlistStore = require("../models/playlist-store");

const playlist = {
  index(request, response) {
    const playlistId = request.params.id;
    logger.debug("Playlist id = ", playlistId);
    const viewData = {
      title: "Playlist",
      playlist: playlistStore.getPlaylist(playlistId)
    };
    response.render("playlist", viewData);
  },

  deleteSong(request, response) {
    const playlistId = request.params.id;
    const songId = request.params.songid;
    logger.debug(`Deleting Song ${songId} from Playlist ${playlistId}`);
    playlistStore.removeSong(playlistId, songId);
    response.redirect("/playlist/" + playlistId);
  },

  addSong(request, response) {
    const playlistId = request.params.id;
    const playlist = playlistStore.getPlaylist(playlistId);
    const newSong = {
      title: request.body.title,
      artist: request.body.artist,
    };
    playlistStore.addSong(playlistId, newSong);
    response.redirect('/playlist/' + playlistId);
  },
};

module.exports = playlist;

Finally, we need to implement a new addSong method in the playlist-store:

modiels/playlist-store.js

  addSong(id, song) {
    const playlist = this.getPlaylist(id);
    playlist.songs.push(song);
  },

This is the complete playlist-store:

"use strict";

const _ = require("lodash");

const playlistStore = {
  playlistCollection: require("./playlist-store.json").playlistCollection,

  getAllPlaylists() {
    return this.playlistCollection;
  },

  getPlaylist(id) {
    return _.find(this.playlistCollection, { id: id });
  },

  removeSong(id, songId) {
    const playlist = this.getPlaylist(id);
    _.remove(playlist.songs, { id: songId });
  },

  removePlaylist(id) {
    _.remove(this.playlistCollection, { id: id });
  },

  addSong(id, song) {
    const playlist = this.getPlaylist(id);
    playlist.songs.push(song);
  },
};

module.exports = playlistStore;

Try the app now and verify that you can add songs.

Think about the following at this stage:

  • Can you delete one of the songs you have just added?
  • Can you delete one of the songs that have been in there already?

What might be happening in the above? (see next step to explore further).

ID Management

Deleting a song you have manually added using the form generates the following:

Cannot GET /playlist/02/deletesong/

However, pre-populated songs can still be deleted. What is going on here?

The issue is that the new songs we have added do not appear to have IDs. Looking at this list for instance:

In the browser, right-click and select 'view source' and you should see something like this:

Look at the last entry - there is no ID for the new song we added.

Here is our addsong function again:

controlers/playlist.js

  addSong(request, response) {
    const playlistId = request.params.id;
    const playlist = playlistStore.getPlaylist(playlistId);
    const newSong = {
      title: request.body.title,
      artist: request.body.artist,
    };
    playlistStore.addSong(playlistId, newSong);
    response.redirect('/playlist/' + playlistId);
  },

The object we are inserting has only two fields:

    const newSong = {
      title: request.body.title,
      artist: request.body.artist,
    };

We need to also insert a new, unique, ID for each object we create.

How do we create a new ID? This can be challenged in implement manually. However, one solution is to use Universally Unique Identifiers:

A library to generate a uuid is already in our app, we just need to import and use it

At the top of the playlist controller, we require the library. Insert this at the top of the modile:

controllers/playlists.js

const uuid = require('uuid');

Now, when we are creating a playlist, we can use the library to generate a unique id for us:

  const newSong = {
    id: uuid(),
    title: request.body.title,
    artist: request.body.artist,
  };

This is the complete playlist module at this stage:

"use strict";

const logger = require("../utils/logger");
const playlistStore = require("../models/playlist-store");
const uuid = require('uuid');

const playlist = {
  index(request, response) {
    const playlistId = request.params.id;
    logger.debug("Playlist id = ", playlistId);
    const viewData = {
      title: "Playlist",
      playlist: playlistStore.getPlaylist(playlistId)
    };
    response.render("playlist", viewData);
  },

  deleteSong(request, response) {
    const playlistId = request.params.id;
    const songId = request.params.songid;
    logger.debug(`Deleting Song ${songId} from Playlist ${playlistId}`);
    playlistStore.removeSong(playlistId, songId);
    response.redirect("/playlist/" + playlistId);
  },

  addSong(request, response) {
    const playlistId = request.params.id;
    const playlist = playlistStore.getPlaylist(playlistId);
    const newSong = {
      id: uuid(),
      title: request.body.title,
      artist: request.body.artist,
    };
    playlistStore.addSong(playlistId, newSong);
    response.redirect('/playlist/' + playlistId);
  },
};

module.exports = playlist;

Try this now and make sure the new songs can be deleted. View the source to reveal the uuid-style ids in the new songs:

In addition, we can add a log statement so that we can see the songs as they are added:

...
    logger.debug('New Song = ', newSong);
...

Adding playlists

Taking the last step as an example, we can quickly implement the necessary form, route, controller function and model update to support adding a new playlist (as opposed to a song).

This is a new addplaylist partial:

views/partials/addplaylist.hbs

<form class="ui stacked segment form" action="/dashboard/addplaylist" method="POST">
  <div class="field">
    <label>Title</label>
    <input placeholder="Title" type="text" name="title">
  </div>
  <button class="ui blue submit button">Add Playlist</button>
</form>

Modify dashboard to append this partial:

views/dashboard.hbs

{{> menu id="dashboard"}}

<section class="ui segment">
  {{> listplaylists}}
  {{> addplaylist}}
</section>

This now requires a new route:

routes.js

router.post('/dashboard/addplaylist', dashboard.addPlaylist);

.. and this is the new method on the dashboard controller:

controllers/dashboard.js

Import the uuid library at the top:

const uuid = require('uuid');

... and then this new method"

...
  addPlaylist(request, response) {
    const newPlayList = {
      id: uuid(),
      title: request.body.title,
      songs: [],
    };
    playlistStore.addPlaylist(newPlayList);
    response.redirect('/dashboard');
  },

Notice we are again using the uuid library here to generate and ID for the new playlist.

Finally, a new method in the playlist-store to add a playlist:

modes/playlist-store.js

addPlaylist(playlist) {
  this.playlistCollection.push(playlist);
},

We should now be able to add new playlists.

Persistence

You will note that, even though we are able to add/delete playlists/songs, every time the app restarts then we are left with the same initial playlist. i.e. none of the changes we make are persisted beyond the life of the program execution.

This is the field of databases - and beyond the scope of this course for the moment. However, we can implement something like a database, but simpler, which can serve our needs. Introduce this new class into the project:

models/json-store.js

'use strict';

const low = require('lowdb');
const FileSync = require('lowdb/adapters/FileSync')

class JsonStore {
  constructor(file, defaults) {
    const adapter = new FileSync(file)
    this.db = low(adapter)
    this.db.defaults(defaults).value();
  }

  save() {
    this.db.write();
  }

  add(collection, obj) {
    this.db.get(collection).push(obj).last().value();
  }

  remove(collection, obj) {
    this.db.get(collection).remove(obj).value();
  }

  removeAll(collection) {
    this.db.get(collection).remove().value();
  }

  findAll(collection) {
    return this.db.get(collection).value();
  }

  findOneBy(collection, filter) {
    const results = this.db.get(collection).filter(filter).value();
    return results[0];
  }

  findByIds(collection, ids) {
    return this.db.get(collection).keyBy('id').at(ids).value();
  }

  findBy(collection, filter) {
    return this.db.get(collection).filter(filter).value();
  }
}

module.exports = JsonStore;

This is a wrapper around this module:

It will manage the json file we have been using, enabling updates in a convenient manner. You do not need to understand this module in depth for the moment, we will use another wrapper module to interact with it.

Here is the wraper, which is a new version of our playlist store module:

models/playlist-store.js

'use strict';

const _ = require('lodash');
const JsonStore = require('./json-store');

const playlistStore = {

  store: new JsonStore('./models/playlist-store.json', { playlistCollection: [] }),
  collection: 'playlistCollection',

  getAllPlaylists() {
    return this.store.findAll(this.collection);
  },

  getPlaylist(id) {
    return this.store.findOneBy(this.collection, { id: id });
  },

  addPlaylist(playlist) {
    this.store.add(this.collection, playlist);
    this.store.save();
  },

  removePlaylist(id) {
    const playlist = this.getPlaylist(id);
    this.store.remove(this.collection, playlist);
    this.store.save();
  },

  removeAllPlaylists() {
    this.store.removeAll(this.collection);
    this.store.save();
  },

  addSong(id, song) {
    const playlist = this.getPlaylist(id);
    playlist.songs.push(song);
    this.store.save();
  },

  removeSong(id, songId) {
    const playlist = this.getPlaylist(id);
    const songs = playlist.songs;
    _.remove(songs, { id: songId});
    this.store.save();
  },
};

module.exports = playlistStore;

No other changes are necessary - as all access to the playlist is via the above module.

The app should work now as before, except this time changes to the playlists will be persisted to the json file. This means that if the app has to be restarted, it will have preserved any changes to the playlist in the JSON file, which will be reloaded when the app restarts.

You can force a reload by deliberately introducing a syntax error - and then correcting it.

However, because of limitations in the way glitch is currently configured the playlist-store.json file will often not be in sync and not be updated at all. If you download and run the application locally (in webstorm), the json file will be updated accurately.

Exercises

If you want to download a complete version of the app as it should be at the end of this lab, then create a new Glitch project, and clone this repo:

You can also clone this repo directly, run 'npm install' from inside the folder and then run from within Webstorm.

Exercise 1: Display Durations

Make sure the duration of each song and playlist is visible on the UX

Exercise 2: Add Song Duration

Modify the add song form to accept a song duration as well as title and artist:

Make sure the duration is stored in the data store, and is displayed on the playlist view.

HINT: When reading a parameter from a form representing a number, you will need to convert it from string. The Number function is one simple way of doing this:

    const newSong = {
      id: uuid(),
      title: request.body.title,
      artist: request.body.artist,
      duration: Number(request.body.duration),
    };

Exercise 3: Active Duration

The durations in each playlist are just constants stored in the database. Currently, if you add a new playlist + songs, then the duration in the playlist will be undefined.

Change this now, such that when you add a song to a playlist the duration displayed on the dashboard for each playlist will be the actual sum of the donations for all songs in the playlist.

HINT: One way of dong this is to update the addSong() method in the playlist-store object:

  addSong(id, song) {
    const playlist = this.getPlaylist(id);
    playlist.songs.push(song);

    let duration = 0;
    // Iterate over the playlist.songs array, and accumulate the duration of each
    // song into the duration local variable.
    // ...
    // ...

    // Now store the duration in playlist.store
    // ...

    this.store.save();
  },