Building a Google Keep Clone with Vue and Firebase, Pt 2

Building a Google Keep clone with Vue and Firebase, Pt 2

Free Course

Build Your First Node.js Website

Node is a powerful tool to get JavaScript on the server. Use Node to build a great website.

In the previous part we created a simple Google Keep Clone where the notes are layed out nicely by the Masonry library.

Notes layed out by Masonry

Though this is a great start, we are still missing a few parts of a typical CRUD application. We still need to write the Update and Delete functionality. Another issue is that our Firebase code is scattered around the different components in our project. We should get rid of duplication and move all our Firebase related code to a single module. As a result we will have an app that is more DRY (Don't Repeat Yourself).

Creating an independent data layer

Instead of putting all the data logic directly in our components, it's a better idea to put it in an independent module.

Putting the shared data logic into a module has multiple benefits: better abstraction, easier refactoring, less duplication or DRY'er, and ensures reusability.

Create a new file in a new folder: src/data/NoteRepository.js. This file will hold all the Firebase code to manage the notes.
Create a class called NoteRepository. This class will contain all the logic to create, update, and delete the notes.

The class will also be responsible for listening to all the Firebase-events inside the class. But the components need to be able to listen to those events too. You can achieve this by inheriting from the EventEmitter class that Node provides (see docs). Once the class extends the EventEmitter, the components can attach listeners to the NoteRepository via the on-method. Within the class you can fire events via the emit-method. Combined with the inherited EventEmitter functionality, the NoteRepository can listen to the Firebase-events, process the data, and propagate the events outwards so the components will receive the processed data.

src/data/NoteRepository.js

import EventEmitter from 'events'

// extend EventEmitter so user of NoteRepository can react to our own defined events (ex: noteRepository.on('added'))
class NoteRepository extends EventEmitter {
  constructor () {
    super()
  }
}
export default new NoteRepository() // this instance will be shared across imports

In the constructor add a property to hold the Firebase reference to our notes. Next attach listeners to the Firebase events. These listeners should create a note-object and propagate the event + note outwards via the emit-method.

Previously, you only needed the title, and content to display the notes. But to update and delete a note, you will need a unique identifier. In Firebase each item has a unique key, which is either set programmatically, or automatically generated by Firebase. You can get the key by issuing the key-function. Before emitting the event, add the key to the note.

src/data/NoteRepository.js

import Firebase from 'firebase'
import EventEmitter from 'events'

// extend EventEmitter so user of NoteRepository can react to our own defined events (ex: noteRepository.on('added'))
class NoteRepository extends EventEmitter {
  constructor () {
    super()
    // firebase reference to the notes
    this.ref = new Firebase('https://<YOUR-FIREBASE-APP>.firebaseio.com/notes') // will have same result as new Firebase('https://<YOUR-FIREBASE-APP>.firebaseio.com/').child('notes')
    this.attachFirebaseListeners()
  }
  // attach listeners to Firebase
  attachFirebaseListeners () {
    this.ref.on('child_added', this.onAdded, this)
    this.ref.on('child_removed', this.onRemoved, this)
    this.ref.on('child_changed', this.onChanged, this)
  }
  onAdded (snapshot) {
    // process data
    let note = this.snapshotToNote(snapshot)
    // propagate event outwards with note
    this.emit('added', note)
  }
  onRemoved (oldSnapshot) {
    let note = this.snapshotToNote(oldSnapshot)
    this.emit('removed', note)
  }
  onChanged (snapshot) {
    let note = this.snapshotToNote(snapshot)
    this.emit('changed', note)
  }
  // processes the snapshots to consistent note with key
  snapshotToNote (snapshot) {
    // we will need the key often, so we always want to have the key included in the note
    let key = snapshot.key()
    let note = snapshot.val()
    note.key = key
    return note
  }
}
export default new NoteRepository() // this instance will be shared across imports

Now the components can listen to the repository, but the create, update, and delete functionality is still missing.
Create: Write a create-method that has a matching signature with the Firebase push-method, and pass the parameters to the push-method. It should accept a note, and an onComplete-callback which is optional.

  create ({title = '', content = ''}, onComplete) {
    this.ref.push({title, content}, onComplete)
  }

Update: Write an update-method that accepts a note object (including key), and an onComplete-callback. To update the note in Firebase you need to get a reference to the specific object. You can create a reference by passing the unique key to the child-function of the notes reference. This will create a new Firebase reference by appending the key to the url of the parent-reference. You can then call the update method on the new child-reference passing in a note object (make sure you don't pass in the key).

  update ({key, title = '', content = ''}, onComplete) {
    this.ref.child(key).update({title, content}, onComplete) // key is used to find the child, a new note object is made without the key, to prevent key being inserted in Firebase
    // this.ref.child(key) will create new reference like this new Firebase(`https://<YOUR-FIREBASE-APP>.firebaseio.com/notes/${key}`)
  }

Delete: Write a remove-method that accepts a note object (including key), and an onComplete-callback. You will only need the key of the note to remove the note in Firebase, but for ease of use and consistency you can just accept object and only take the key. Get the reference to the note via the child-method and call the remove-method.

  // removes a note
  remove ({key}, onComplete) {
    this.ref.child(key).remove(onComplete)
  }

You just created an independent class that manages the access and manipulation of the notes. When you put all the code together, the class should look like this. (Note that there are some extra helper methods that you'll use down the line of this part).

src/data/NoteRepository.js

import Firebase from 'firebase'
import EventEmitter from 'events'

// extend EventEmitter so user of NoteRepository can react to our own defined events (ex: noteRepository.on('added'))
class NoteRepository extends EventEmitter {
  constructor () {
    super()
    // firebase reference to the notes
    this.ref = new Firebase('https://<YOUR-FIREBASE-APP>.firebaseio.com/notes') // will have same result as new Firebase('https://<YOUR-FIREBASE-APP>.firebaseio.com/').child('notes')
    this.attachFirebaseListeners()
  }
  // creates a note
  create ({title = '', content = ''}, onComplete) {
    this.ref.push({title, content}, onComplete)
  }
  // updates a note
  update ({key, title = '', content = ''}, onComplete) {
    this.ref.child(key).update({title, content}, onComplete) // key is used to find the child, a new note object is made without the key, to prevent key being inserted in Firebase
    // this.ref.child(key) will create new reference like this new Firebase(`https://<YOUR-FIREBASE-APP>.firebaseio.com/notes/${key}`)
  }
  // removes a note
  remove ({key}, onComplete) {
    this.ref.child(key).remove(onComplete)
  }
  // attach listeners to Firebase
  attachFirebaseListeners () {
    this.ref.on('child_added', this.onAdded, this)
    this.ref.on('child_removed', this.onRemoved, this)
    this.ref.on('child_changed', this.onChanged, this)
  }
  // dettach listeners from Firebase
  detachFirebaseListeners () {
    this.ref.off('child_added', this.onAdded, this)
    this.ref.off('child_removed', this.onRemoved, this)
    this.ref.off('child_changed', this.onChanged, this)
  }
  onAdded (snapshot) {
    // process data
    let note = this.snapshotToNote(snapshot)
    // propagate event outwards with note
    this.emit('added', note)
  }
  onRemoved (oldSnapshot) {
    let note = this.snapshotToNote(oldSnapshot)
    this.emit('removed', note)
  }
  onChanged (snapshot) {
    let note = this.snapshotToNote(snapshot)
    this.emit('changed', note)
  }
  // processes the snapshots to consistent note with key
  snapshotToNote (snapshot) {
    // we will need the key often, so we always want to have the key included in the note
    let key = snapshot.key()
    let note = snapshot.val()
    note.key = key
    return note
  }
  // Finds the index of the note inside the array by looking for its key
  findIndex (notes, key) {
    return notes.findIndex(note => note.key === key)
  }
  // Finds the note inside the array by looking for its key
  find (notes, key) {
    return notes.find(note => note.key === key)
  }
}
export default new NoteRepository() // this instance will be shared across imports

Note that you are not using any vue related code. You can drop in our file into another framework without any problems. You have taken a great step to make the code more reusable!

Refactoring the components to use the data layer

Now that you have created a data layer, you can replace all the Firebase code with methods from the NoteRepository.

Notes component

Import the NoteRepository instance in the Notes-component and implement the events of the NoteRepository in the ready-method.

Added: Add the newly created note at the start of the notes array.

src/components/notes/Index.vue

...
import noteRepository from '../../data/NoteRepository'
export default {
  ...
  ready () {
    ...
    noteRepository.on('added', (note) => {
      this.notes.unshift(note) // add the note to the beginning of the array
    })
    ...
  }
}

Changed: Find the note that in the notes-array that is outdated. You can use the find helper method provided by NoteRepository for this. Once you found the outdated note, update its title and content.

src/components/notes/Index.vue

    ....
    noteRepository.on('changed', ({key, title, content}) => {
      let outdatedNote = noteRepository.find(this.notes, key) // get specific note from the notes in our VM by key
      outdatedNote.title = title
      outdatedNote.content = content
    })
    ...

Removed: Find the note that needs to be removed and use the $remove-method on the notes-array passing in the note that needs to be removed. The $remove-method is a convenience method added by Vue (docs).

src/components/notes/Index.vue

    ...
    noteRepository.on('removed', ({key}) => {
      let noteToRemove = noteRepository.find(this.notes, key) // get specific note from the notes in our VM by key
      this.notes.$remove(noteToRemove) // remove note from notes array
    })
    ...

When a user adds, updates, or removes a note, everyone using the application will see the changes automatically!

Though masonry isn't laying out the notes anymore. You could encapsulate the masonry code in a method, and call it at the end of every callback. But let's use another Vue feature instead. In Vue you can listen to any changes to an array or object via the watch-property (docs) or via the $watch-method (docs).

Let's listen to all changes in the notes-array and tell masonry to layout the notes when something changes. By default Vue will only watch for changes to the array and not the changes inside the individual items contained in the array. To let Vue watch for changes inside those items you can pass in the deep: true option. Also note that you have to initialize the masonry-property in the ready-method so you can reference it from anywhere in the component.

src/components/notes/Index.vue

export default {
  ...
  watch: {
    'notes': { // watch the notes array for changes
      handler () {
        this.masonry.reloadItems()
        this.masonry.layout()
      },
      deep: true // we also want to watch changed inside individual notes
    }
  },
  ready () {
    this.masonry = new Masonry(this.$els.notes, {
      itemSelector: '.note',
      columnWidth: 240,
      gutter: 16,
      fitWidth: true
    })
    ...
  }
}

Putting it all together your code should look like this.

src/components/notes/Index.vue

import Masonry from 'masonry-layout'
import Note from './Note'
import noteRepository from '../../data/NoteRepository'
export default {
  components: {
    Note
  },
  data () {
    return {
      notes: []
    }
  },
  watch: {
    'notes': { // watch the notes array for changes
      handler () {
        this.masonry.reloadItems()
        this.masonry.layout()
      },
      deep: true // we also want to watch changed inside individual notes
    }
  },
  ready () {
    this.masonry = new Masonry(this.$els.notes, {
      itemSelector: '.note',
      columnWidth: 240,
      gutter: 16,
      fitWidth: true
    })
    noteRepository.on('added', (note) => {
      this.notes.unshift(note) // add the note to the beginning of the array
    })
    noteRepository.on('changed', ({key, title, content}) => {
      let outdatedNote = noteRepository.find(this.notes, key) // get specific note from the notes in our VM by key
      outdatedNote.title = title
      outdatedNote.content = content
    })
    noteRepository.on('removed', ({key}) => {
      let noteToRemove = noteRepository.find(this.notes, key) // get specific note from the notes in our VM by key
      this.notes.$remove(noteToRemove) // remove note from notes array
    })
  }
}

Now the Notes-component is working as before again, and responding to the changed/removed event! Another step closer to a full CRUD application. You can test this out by changing and removing notes with the Vulcan chrome extension or in the Firebase interface.

Create note component

Simply import the NoteRepository and remove the Firebase line with the NoteRepository's create-method.

src/components/notes/Create.vue

import noteRepository from '../../data/NoteRepository'

export default {
  data () {
    return {
      title: '',
      content: ''
    }
  },
  methods: {
    createNote () {
      if (this.title.trim() || this.content.trim()) {
        noteRepository.create({title: this.title, content: this.content}, (err) => {
          if (err) throw err // TODO: inform the user
          this.title = ''
          this.content = ''
        })
      }
    }
  }
}

Although there is no visible change in the application, you just removed all the duplicate Firebase code! Great job!

Deleting notes

Let's write the delete functionality first, because it is just so few lines of code. First add the font-awesome icon-font to your index.html.

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
    <title>Google Keep clone built with Vue and Firebase</title>
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.6.1/css/font-awesome.min.css">
  </head>
  <body>
    <app></app>
    <!-- built files will be auto injected -->
  </body>
</html>

Then in the Note-component add a button with the trash-icon. Use the v-on:click.stop directive and bind it to the remove-method. In the remove method pass the note to the remove-method of the NoteRepository. Also add an edit button with a pencil-icon for later.

src/components/notes/Note.vue

<template>
  <div class="note">
    <h1>{{note.title}}</h1>
    <pre>{{note.content}}</pre>
    <button type="button" v-on:click.stop="remove">
      <i class="fa fa-trash-o" aria-hidden="true"></i>
    </button>
    <button class="edit" type="button">
      <i class="fa fa-pencil" aria-hidden="true"></i>
    </button>
  </div>
</template>
<script>
import noteRepository from '../../data/NoteRepository'
export default {
  props: ['note'],
  methods: {
    remove () {
      noteRepository.remove(this.note, (err) => {
        if (err) throw err // TODO: inform the user
      })
    }
  }
}
</script>
<style>
  .note{
    background: #fff;
    border-radius: 2px;
    box-shadow: 0 2px 5px #ccc;
    padding: 10px;
    margin: 8px 0;
    width: 240px; /* collumn size */
    transition: box-shadow .5s;
    cursor: default;
  }
  .note h1{
    font-size: 1.1em;
    margin-bottom: 6px;
    word-wrap: break-word;
  }
  .note pre {
    font-size: 1.1em;
    margin-bottom: 10px;
    white-space: pre-wrap;
    word-wrap: break-word;
    font-family: inherit;
  }
  .note button{
    background: none;
    border: none;
    font-size: 20px;
    opacity: 0;
    cursor: pointer;
    transition: opacity .5s;
    margin: 0 4px 0 0;
  }
  .note button.edit{
    float: right;
  }
  .note:hover, .note:focus{
    box-shadow: 0 2px 10px #999;
  }
  .note:hover button, .note:focus button{
    opacity: 0.6;
  }
  .note button:hover, .note button:focus{
    opacity: 1;
  }
</style>

And you're done! When you hover over a note, it should look like this. Hovering over a note On click of the trash button, the note will be removed from Firebase, and when it is being removed, Firebase will fire the removed event, which the Notes-component is listening to and will remove the note from the array.

Updating notes

Create a new file src/components/notes/UpdateModal.vue. This component will be a modal containing a form to update a specific note. Similarly to the Note-component, define a property on the Update-component to pass through the note to be updated. In the template, create a form similar to the form in the Create-component and bind the input and textarea to the title and content. Now bind the submit-event of the form to a new method called 'update'. Inside this method, pass through the note to the update method of the NoteRepository and dismiss the modal in the callback.

Use v-if="note" on the root element so that the modal will only be shown when a note is bound to it. To dismiss the modal, simply set the note to null. Also add another trash-button and bind the click event to a remove method which calls the remove method of the NoteRepository.

src/components/notes/UpdateModal.vue

<template>
  <div v-if="note" transition="modal" class="backdrop" v-on:click="dismissModal">
    <form class="edit-note" v-on:submit.prevent="update" v-on:click.stop="">
      <input name="title" v-model="note.title" placeholder="Title"/>
      <textarea name="content" v-model="note.content" placeholder="Text goes here..." rows="8">
      </textarea>
      <button type="button" v-on:click="remove">
        <i class="fa fa-trash-o" aria-hidden="true"></i>
      </button>
      <button type="submit">Done</button>
    </form>
  </div>
</template>
<script>
  import noteRepository from '../../data/NoteRepository'
  export default {
    props: ['note'],
    methods: {
      remove () {
        noteRepository.remove(this.note, (err) => {
          if (err) throw err // TODO: inform the user
          this.dismissModal()
        })
      },
      update () {
        noteRepository.update(this.note, (err) => {
          if (err) throw err // TODO: inform the user
          this.dismissModal()
        })
      },
      dismissModal () {
        this.note = null
      }
    }
  }
</script>
<style>
  .backdrop{
    position: fixed;
    left: 0;
    top: 0;
    right: 0;
    bottom: 0;
    background: rgba(50,50,50,0.8);
  }
  form.edit-note{
    position: relative;
    width: 480px;
    max-width: 100%;
    margin: 25vh auto 0;
    background: #fff;
    padding: 15px;
    border-radius: 2px;
    box-shadow: 0 1px 50px #555;
  }
  form.edit-note input, form.edit-note textarea{
    width: 100%;
    max-width: 100%;
    border: none;
    padding: 4px;
    outline: none;
    font-size: 1.2em;
  }
  form.edit-note button[type=submit]{
    font-size: 18px;
    float: right;
    background: #41b883;
    color: #fff;
    border: none;
    border-radius: 3px;
    opacity: 1;
    cursor: pointer;
    padding: 4px 6px;
    margin: 0;
  }
  form.edit-note button{
    background: none;
    border: none;
    font-size: 20px;
    opacity: 0.6;
    cursor: pointer;
    transition: opacity .5s;
    margin: 0 4px 0 0;
  }
  form.edit-note button:hover, form.edit-note button:focus{
    opacity: 1;
  }

  /* modal transition */
  .modal-transition{
    transition: opacity .3s ease;
    opacity: 1;
  }
  .modal-transition form{
    transition: transform .3s ease;
  }
  .modal-enter, .modal-leave {
    opacity: 0;
  }

  .modal-enter form,
  .modal-leave form {
    -webkit-transform: scale(1.1);
    transform: scale(1.1);
  }
</style>

Instead of using a library for this modal, you can simply rely on CSS in combination with the transition feature of Vue to create a good looking modal. Transition in Vue is similar to how Angular Animate works. When you apply directives like v-if, v-show, v-for, etc. it will apply classes to the element so you can hook up your own CSS transitions.
When you set transition="modal", Vue will set the class .modal-transition for you to setup the default state. When the state of v-if="note" changes to truthy, Vue will add the .modal-enter class and remove it when your animation has finished. When the state becomes falsy, it will add the .modal-leave class instead.

The div.backdrop is the dark overlay above all the other content. On the click of the backdrop, you should also dismiss the modal. To make sure the click-event isn't fired when clicking inside the form, you should add v-on:click.stop="" which will stop the event from bubbling up. Using a transition you can fade in the backdrop, and apply a scale transformation to the form for that nice modal effect.

/* modal transition */
.modal-transition{
  transition: opacity .3s ease;
  opacity: 1;
}
.modal-transition form{
  transition: transform .3s ease;
}
.modal-enter, .modal-leave {
  opacity: 0;
}

.modal-enter form,
.modal-leave form {
  -webkit-transform: scale(1.1);
  transform: scale(1.1);
}

Now that the update-modal is finished you can check out how it looks by adding the Update-component to the App-component and temporarily hardcoding the note-attribute.

<template>
  <div>
    <create-note-form></create-note-form>
    <notes></notes>
    <update-modal :note="{title: 'test', content: 'lorem ipsum'}"></update-modal>
  </div>
</template>
<script>
  import Notes from './components/notes/Index'
  import CreateNoteForm from './components/notes/Create'
  import UpdateModal from './components/notes/UpdateModal'
  export default {
    components: {
      Notes,
      CreateNoteForm,
      UpdateModal
    }
  }
</script>

The modal should appear and look like the picture below. When you click on the backdrop, the modal should disappear with a smooth animation. (The update and delete button are not working because they expect the note has a key) Update-modal

Now that the component is ready, let's integrate it with the rest of the app.
When a user clicks on a note, the update-modal should appear. So the Notes-component should be able to tell its parent that a note has been selected. In the previous part, you already communicated from a parent component (notes) to a child component (note) through the attributes of the child. Another way to communicate between parent and child component is in the form of events. In a Vue component, there are three different ways to fire an event.

  1. $emit will fire an event on itself. This means you can listen to the event within the component, but parents and descendants/children will not receive the event.
  2. $dispatch will send the event to the parent components
  3. $broadcast will send the event to the descendants/children of the component

Since the Notes-component needs to communicate with its parent, you will need to use $dispatch. In the Notes-component, add a new method 'selectNote' accepting a note. In this method, fire an event 'note.selected' and pass in the note as the second argument. This method needs to be fired on the click of a note. Bind the 'selectNote' method on the notes using v-on:click="(note)".

src/components/notes/Index.vue

<template>
  <div class="notes" v-el:notes>
    <note
      v-for="note in notes"
      :note="note"
      v-on:click="selectNote(note)"
      >
    </note>
  </div>
</template>
<script>
  ...
  export default {
    ...
    methods: {
      selectNote ({key, title, content}) {
        // notify listeners that user selected a note
        // pass in a copy of the note to prevent edits on the original note in the array
        this.$dispatch('note.selected', {key, title, content})
      }
    },
    ...
  }
</script>

Now in the App-component you can listen to the event and pass the note to the modal via selectedNote.

src/App.vue

<template>
  <div>
    <create-note-form></create-note-form>
    <notes></notes>
    <update-modal :note.sync="selectedNote"></update-modal>
  </div>
</template>
<script>
  import Notes from './components/notes/Index'
  import CreateNoteForm from './components/notes/Create'
  import UpdateModal from './components/notes/UpdateModal'
  export default {
    components: {
      Notes,
      CreateNoteForm,
      UpdateModal
    },
    data () {
      return {
        selectedNote: null
      }
    },
    events: {
      'note.selected': function (note) {
        this.selectedNote = note
      }
    }
  }
</script>

When the user clicks on a note, the Notes-component will fire the 'note.selected'-event. The App-component will listen to the event and set the selectedNote. Because of the binding the update-modal will appear instantly. To dismiss the modal, the note will be set to null in the modal's viewmodel.
Although the note inside the modal is null, the selectedNote still contains the note-object. That's because by default Vue uses one-way binding. When the App-component adjusts the selectedNote, the note inside the modal will also be updated, but not the other way around. To adjust the type of binding, you can pick between two modifiers: :note.sync="selectedNote" for two-way binding and :note.once="selectedNote" for one-time binding.

  • Two-way binding: will make make sure the changes in the selectedNote will update the note inside the modal, and the changes to the note in the modal will also sync back to the selectedNote.
  • One-time binding: will bind the value one time, and won’t sync from then on.

To make sure that the selectedNote in the App-component and the note in the update-modal are in sync, you should use the .sync modifier.

Now you have a fully functional CRUD application! Great job!

Wrapping up

We finally have our fully functional CRUD application. Note that we could have used the Vue-router to bind a URL to our update modal, but for the size of this app our approach is an appropriate solution.

Although, this part might seem like a big chunk, we were still able to maintain a clean architecture and keep our code short (~450 lines including HTML/CSS) and readable.

  • We refactored all our Firebase code to an independent data-layer
  • We added Update and Delete functionality
  • With relatively little code we created a fully functional CRUD application

Now there is only one part left to tackle: authentication and authorization. In the last part we will create a login/register form and use the built-in authentication and authorization mechanisms of Firebase.

Don't hesitate to ping me if you have any issues!

Niels Swimberghe

I am a Belgian developer specialized in building apps. I studied New Media and Communication Technology and graduated with a focus on programming. I love building delightful digital experiences with the newest technologies available. Building something that helps people and makes users happy is what drives me.

I am currently looking for opportunities on the US east coast (DC, NY, ...), feel free to get in touch with me to discuss options.