Vue the Mirage from this angle!

bhaidar

Bilal Haidar

Posted on January 7, 2020

Vue the Mirage from this angle!

Despite being designed in the late 1970’s, the lightweight Mirage jet fighter is the topic of today’s article!

You might assume that I’m kidding, but I’m not!

We are going to look at Mirage from a different angle in this article. The Vue.js angle!

In the early stages of full-stack app development, everything is subject to change. The database structure is not complete, Web APIs are still under construction, and the front-end development is on-going. Building the layout, and writing all HTML/CSS can be a much faster process when you have a specific design in mind.

At some point, it's not unusual for the front-end developers to be stalled or blocked by the incomplete back-end APIs. They need to populate some dynamic sections based on data eventually coming through from the backend.

Solutions for this problem, which I’ve employed in the past, include using mock files or in-memory databases to see how things will pan out. The down side? It does come with its hiccups.

That’s why Mirage.js exists! Mirage.js is a client-side server that embeds itself into the app. It handles all of the API endpoints calls without the need for a real backend server.

This article will introduce to you Mirage.js as we'll build a Note Writer app in Vue.js.

The source code of this article can be cloned from this GitHub repo: Notes Writer.

How can Mirage.js help?

Developing an app with the backend API development lagging can bring things to a halt for the front-end team. That’s why we tend to use workarounds like reading from mock files, or even building fake in-memory services to simulate API responses. This enables us to build the front-end, display and render any dynamic data, and get a full picture of what the app looks like once it’s done.

For instance, each mock file mimics a separate database entity. You then add client-side Web services to read the mock files, and make their content available to the app. When the backend APIs are ready, you simply visit these services, and amend the code to interact with the real API endpoints instead. That’s easy, but time is wasted replacing the mock Web services with the real ones.

This approach creates extra work for the developer, and extensive time later coding the services to communicate with the real API endpoints.

Mirage.js offers a simple solution for this: an approach that requires no code changes at all when it’s time to use the real API endpoints.

With Mirage.js, you create an instance of a Server object. This object is the window through which Mirage.js interacts with your application and handles all API endpoints.

Routes and Route Handlers

Inside this Server object instance, you define a set of routes and route handlers. A route and a route handler look like this:

this.get("/notes", () => {
    return [
        { id: "1", body: "The first note body goes here", title: "First Note" },
        { id: "2", body: "The second note body goes here", title: "Second Note" },
        { id: "3", body: "The third note body goes here", title: "Third Note" }
    ];
})
Enter fullscreen mode Exit fullscreen mode

For a moment, you feel you are writing some Node.js backend code!

This snippet defines a GET route having an endpoint of /notes. The second parameter of the get() route function defines the route handler, that is, the code that runs when the route /notes is called. The route handler returns notes data.

You can define more routes and route handlers as your app needs. For instance, you can define all CRUD routes including POSTING a new note, PATCHING an existing note, and DELETING an existing note.

Mirage.js supports all HTTP Verbs. In addition, to simulate a real API response delay, Mirage.js allows you to pass, as a third argument to the route function, a timing object that specifies the amount of delay in milliseconds to incur before returning a response to the client-side app. You can define this timing object as:

this.get("/notes", () => {
    return [
        { id: "1", body: "The first note body goes here", title: "First Note" },
        { id: "2", body: "The second note body goes here", title: "Second Note" },
        { id: "3", body: "The third note body goes here", title: "Third Note" }
    ];
}, { timing: 5000 })
Enter fullscreen mode Exit fullscreen mode

Mirage.js Database and ORM

So far, we introduced the static route handlers. The route handler statically defines the set of data to return every time the route is requested.

What if you want the ability to add new notes? Delete existing? Edit existing? Then you can use the dynamic route handlers supported by Mirage.js.

In order to support dynamic data, Mirage.js offers an in-memory database that is accessed by the server.db object instance.

Along with the database support, Mirage.js comes with an ORM. You will spend most of your time working with the ORM instead of accessing the database directly. With the ORM, you can create, update, delete, and query for data inside the database.

For the ORM/Database integration to work, you should define your models inside the Server object. The models represent the entities you are dealing with within your application. Also, models are registered by the ORM that use them to shape the data returned from the database.

To define a model in your Server object, you:

import { Server, Model } from "miragejs";

new Server({
    models: {   
        note: Model
    },

    routes () {
        this.namespace = "api";

        this.get("/notes", schema => {
            return schema.notes.all();
        })
    }
});

Enter fullscreen mode Exit fullscreen mode

You wrap all your models inside the models object. Any model you define is of type Model class.

Internally, Mirage.js stores a collection of notes. By default, it pluralizes the model name you have given. Now you can add/edit/remove notes from this collection.

Then, you refactor the route handler to make use of the schema object. The schema object is provided as the first parameter on the route handler. It represents the ORM gateway to access all the collections stored inside the database.

The schema object adds functionality to the collections. For example, it adds a set of functions that are essential to retrieve, edit, create, and delete data from the collections.

schema.notes.create();
schema.notes.all();
schema.notes.find();
schema.notes.findBy();
schema.notes.where();
schema.notes.destory();
Enter fullscreen mode Exit fullscreen mode

By using the ORM API, you let Mirage.js shape and use internal serializers to format your data as JSON, or any other supported format, and make your data ready to be transmitted to the front-end app.

A second parameter that a route handler accepts is the request parameter. The request parameter represents the current HTTP Request object.

To complete this section, it’s worth mentioning the seeds() function that you can use to seed your database with some initial data:

import { Server, Model } from "miragejs";

new Server({
    models: {   
        note: Model
    },

    routes () {
        this.namespace = "api";

        this.get("/notes", schema => {
            return schema.notes.all();
        })
    },

    seeds (server) {
        server.create("note", { body: "# An h1 header", title: "# An h1 header" });
        server.create("note", { body: "## An h2 header", title: "## An h2 header" });
        server.create("note", { body: "### An h3 header", title: "### An h3 header" });
        server.create("note", { body: "#### An h4 header", title: "#### An h4 header" });
        server.create("note", { body: "##### An h5 header", title: "##### An h5 header" });
        server.create("note", { body: "###### An h6 header", title: "###### An h6 header" });
    }
});

Enter fullscreen mode Exit fullscreen mode

The seeds() function accepts the server object as an input parameter. You make use of the server.create() function to seed initial data in the database.

One important aspect of Mirage.js is that it auto-generates values for the id field for each and every record in the notes collection.

The Mirage.js team has done a great job of documenting this product. You can consult the documentation website to read more about Mirage.js.

Demo

Now that we've laid down the basics of Mirage.js, let’s start using it to build a Vue.js Notes Writer app.

Create a new Vue.js app

To start, make sure you install the latest version of the Vue CLI by running this command:

npm install -g @vue/cli

The command downloads and installs the latest bits of the Vue CLI on your computer.

To verify the version of the Vue CLI installed, you can run this command:

vue --version

The command displays:

@vue/cli 4.1.1

To create a new Vue.js app, run the following command:

vue create notes-writer-app

This command triggers a set of questions to shape the features to be included in the new app. You are free to choose the options that suit your app. In my case, I’ve chosen the following:

Vue CLI v4.1.1
? Please pick a preset:
  default (babel, eslint)
❯ Manually select features

? Check the features needed for your project:
 ◉ Babel
 ◯ TypeScript
 ◯ Progressive Web App (PWA) Support
 ◯ Router
 ◉ Vuex
 ◉ CSS Pre-processors
 ◉ Linter / Formatter
❯◯ Unit Testing
 ◯ E2E Testing

? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default)
:
  Sass/SCSS (with dart-sass)
❯ Sass/SCSS (with node-sass)
  Less
  Stylus

? Pick a linter / formatter config:
  ESLint with error prevention only
  ESLint + Airbnb config
❯ ESLint + Standard config
  ESLint + Prettier

? Pick additional lint features:
❯◯ Lint on save
 ◉ Lint and fix on commit

? Where do you prefer placing config for Babel, ESLint, etc.?
  In dedicated config files
❯ In package.json
Enter fullscreen mode Exit fullscreen mode

The CLI takes a few seconds to download and install all the bits. Once done, you can navigate to the newly created app folder and run the app:

cd notes-writer-app
npm run serve
Enter fullscreen mode Exit fullscreen mode

Now that the Vue.js app is created, let’s install the required NPM packages for this app.

Install NPM dependencies

A few additional NPM packages are required by the Notes Writer app. To install them, follow the steps below:

npm install --save axios

This command installs the axios library to allow us to communicate with a backend API using HTTP Requests.

npm install --save lodash

This command installs the lodash library. This library gives us a handful set of JavaScript functions to perform complex tasks.

npm install --save marked

The command above installs the marked library. This library is used to convert Markdown text into HTML content. The Notes Writer app allows the user to write his/her notes using Markdown text. With the help of this library, the app shows the converted content in HTML right away.

npm install --save miragejs

The command above installs the miragejs library. This library will be used to build a client-side server to handle backend API requests.

One more dev-dependency is required since we will be using SASS is the sass library itself. You can install it by running:

npm install --save-dev sass

Those are all the NPM packages we need.

Let’s move on and introduce the Notes Writer app.

Introduce the UI

Before we start building the Notes Writer app, let me share the final app, and what it looks like when running it in a browser:

Screenshot 2019-12-07 at 10.16.31 PM

The app is split into two main sections:

  • Notes List: This section lists all the saved notes in the app.
  • Notes Editor: This section provides an editor to input the note content (Markdown or normal text). In addition, it displays a live preview of whatever text is typed inside the editor.

When the user starts typing, the Save and Clear buttons appear. The Save button is used to save the new or existing notes, while the Clear button clears the editor and disregards any text inside the editor.

The user can click a saved note in order to edit its content. When an existing note is being edited, an additional button named Delete appears to allow the user to delete an existing note.

Let’s start building this app!

Build the UI

Let’s start by building the Notes component. The template of this component is as follows:

<template>
  <div class="notes-container">
    <NotesList
      :notes="notes"
      @set-note="setNote"
      class="notes-container__list"
    ></NotesList>
    <NotesCreate
      :note="currentNote"
      @save-note="saveNote"
      @set-note="setNote"
      @delete-note="deleteNote"
      class="notes-container__create"
    ></NotesCreate>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

The template above embeds two other components:

  • NotesList component. This component receives a collection of note records as input.
  • NotesCreate component. This component receives the currently active note (being edited or viewed) as input.

The NotesList component exposes an event set-note that is emitted when the user clicks on an existing note to read or edit.

The NotesCreate component exposes a set of events:

  • save-note, emitted when the user clicks on the Save button to save a note.
  • set-note, emitted when the user starts typing a new note. The app employs a mechanism that keeps track of what the user is typing, and emits the set-note event. This event is handled by the Vuex Store to save the contents of the current note in a temporary placeholder inside the Store. In addition, this event is emitted when the user clicks on the Clear button to clear the editor.
  • delete-note emitted when the user clicks on the Delete button to delete an existing note.

The Notes component handles all the events emitted by its children components, and redirects them to the Vuex Store, where they are handled in one centralized place.

This component and the rest of the components use scoped SCSS in their templates:

<style lang="scss" scoped>
    @import "@/styles/components/notes.scss";
</style>
Enter fullscreen mode Exit fullscreen mode

I’ve placed all the SCSS files inside a single /src/styles folder with the following structure:

  • components folder holding all the SCSS files for all components.
  • _variables.scss file to hold all SCSS variables used in the app
  • global.scss file to hold a reference to the _variables.scss file or any other SCSS that needs to be shared among the components style sheet files.

You may add additional subfolders in your apps depending on the need and scenario at hand.

In order to make the global.scss available to all the components in the app you need to create a new file (or modify the exising one) at the root of the project folder named vue.config.js and paste the following content inside it:

// vue.config.js
module.exports = {
  css: {
    loaderOptions: {
      sass: {
        prependData: `@import "@/styles/global.scss";`
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The global.scss file will be prepended to any other SCSS files in the app and will be available everywhere.

The NotesList component template is as follows:

<template>
  <div class="notes">
    <div class="notes__items">
      <Note
        v-for="(note, index) in notes"
        :key="index"
        v-bind="note"
        class="notes__items__item"
        @set-note="setNote(note)"
      ></Note>
    </div>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

This component iterates over the collection of note records and renders each note inside its own NoteItem component. As well, it handles the set-note event that is triggered by the NoteItem component.

The NoteItem component template is as follows:

<template>
  <div
    class="notes__item"
    @click.prevent="setNote"
  >
    <h4>{{ title }}</h4>
    <p class="notes__item__body">{{ body }}</p>
  </div>
</template>

Enter fullscreen mode Exit fullscreen mode

The component displays the Note title and body.

The NotesCreate component template is as follows:

<template>
  <div class="editor__md">
    <div class="editor">
      <div v-if="showControls">
        <button
          class="btn btn-new-note"
          @click="saveNewNote"
        >Save</button>
        <button
          class="btn btn-clear-note"
          @click="resetNote"
        >Clear</button>
        <button
          class="btn btn-delete-note"
          v-if="showDeleteBtn"
          @click="deleteNote"
        >Delete</button>
      </div>
      <textarea
        name="markdown"
        :value="currentNote"
        @input="onNoteChanged"
        placeholder="Type your note here ..."
      ></textarea>
    </div>
    <div class="editor__compiled-md">
      <div v-html="compiledMarkdown"></div>
    </div>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

This template is a bit more involved. It starts by displaying the Control Buttons. Then it displays the textarea that’s used as an editor. Finally, it displays the compiled Markdown to the right side of the editor.

The Control buttons are shown based on the showControls computed property that’s defined as follows:

currentNote () {
    return this.note && this.note.body
},
showControls () {
  return !!this.currentNote
},
Enter fullscreen mode Exit fullscreen mode

If there is a current note (whether a new one or an existing one selected) to show the Controls buttons.

As for the Delete button, it’s visibility is controlled by the showDeleteBtn button defined as follows:

showDeleteBtn () {
    return !!this.note.id
},
Enter fullscreen mode Exit fullscreen mode

Show the Delete button only when an existing note is being edited.

The rest of the UI files in the app are related to styling that you can check and go through by visiting the GitHub repo of this app.

Build the Server with Mirage.js

Open the main.js file, and add the following just before the instantiation of the main Vue instance:

import { Server, Model } from 'miragejs'

/* eslint-disable no-new */
new Server({
    models: {
        note: Model
    },

    routes () {
        this.namespace = 'api'

        this.get('/notes', schema => {
          return schema.notes.all()
        })

        this.post('/notes', (schema, request) => {
          let attrs = JSON.parse(request.requestBody)
          let newNote = schema.notes.create(attrs)
          return schema.notes.find(newNote.id)
        })

        this.patch('/notes/:id', (schema, request) => {
          let newAttrs = JSON.parse(request.requestBody)
          let id = request.params.id
          let note = schema.notes.find(id)

          return note.update(newAttrs)
        })

        this.delete('/notes/:id', (schema, request) => {
          let id = request.params.id
          return schema.notes.find(id).destroy()
        })
    },

    seeds (server) {
        server.create('note', { body: '# An h1 header', title: '# An h1 header' })
        server.create('note', { body: '## An h2 header', title: '## An h2 header' })
        server.create('note', { body: '### An h3 header', title: '### An h3 header' })
        server.create('note', { body: '#### An h4 header', title: '#### An h4 header' })
        server.create('note', { body: '##### An h5 header', title: '##### An h5 header' })
        server.create('note', { body: '###### An h6 header', title: '###### An h6 header' })
    }
});
Enter fullscreen mode Exit fullscreen mode

The server instance defines the CRUD routes and route handlers. For instance, the post() route handler parses the request.requestBody to access the payload of this post request. It then uses the ORM schema object to create a new note record. Finally, it uses the ORM schema object to find the newly created note, and returns it to the front-end app.

The server makes use of the seeds() function to initialize the database with some preliminary set of note records.

The routes() function sets the namespace to a value of api. Consequently, this means that any route URL will be suffixed with the /api URL segment. To get all notes stored in the database, you issue a GET request to /api/notes.

Now that the Mirage.js backend server is ready, let’s move on and build the Vuex Store.

Build the Vuex Store

The Store defines the following state:

state: {
    notesList: [],
    note: {}
},
Enter fullscreen mode Exit fullscreen mode

It defines the notesList array to hold the list of all notes in the app, and defines the note object to hold the currently selected or newly created note.

The Store defines a set of actions:

async getNotesList ({ commit }) {
  let notes = [];

    await axios.get('/api/notes')
    .then(response => {
        notes = response.data.notes
    });

    commit('setNotesList', notes)
},
Enter fullscreen mode Exit fullscreen mode

This action issues a GET request to /api/notes to retrieve all notes stored in the database. It then commits the notes into the store by calling the setNotesList mutation.

The setNotesList mutation is defined as follows:

setNotesList (state, notes) {
    state.notesList = notes
},
Enter fullscreen mode Exit fullscreen mode

Another important action is the setNote action defined as:

setNote ({ commit }, { id = '', body = '' } = {}) {
    commit('setNote', { id, body })
},
Enter fullscreen mode Exit fullscreen mode

This action is triggered when the user selects an existing note. Also, it is being triggered while the user is editing an existing note or creating a new note. It receives, as input, the id and body of the note. It commits the data into the store by calling the setNote mutation.

The setNote mutation is defined as:

setNote (state, { id, body }) {
    let note = {}

    if (id) {
        note = state.notesList.find(note => note.id === id)
        const newNoteBody = body || note.body

        note = { ...note, body: newNoteBody, title: newNoteBody.substring(0, 20) }
    } else if (body) {  
        note = { body, title: body.substring(0, 20) }
    }

    state.note = note
},
Enter fullscreen mode Exit fullscreen mode

If the id input parameter is valid, it retrieves the existing note from the state.notesList array, and updates the content of the existing note. Otherwise, this is a new note. The code checks if the user has typed any content. If it does, it creates a new note record.

Finally, it sets the state.note to either the existing note or to the newly created note. This also covers the case in which the user clears the content of the editor, and resets the state.note object.

Another action that’s defined by the Store is the deleteNote action, which is defined as:

async deleteNote ({ commit, state }) {
    let id = (state.note && state.note.id)

    if (id) {
        let url = `/api/notes/${state.note.id}`
        await axios.delete(url)
    }

    commit('deleteNote', { id })
},
Enter fullscreen mode Exit fullscreen mode

The action retrieves the note ID of the note currently stored inside the state.note object. If one exists, this means that there is an existing note that’s being edited right now. It then issues a DELETE Http Request to remove this note. Finally, it commits the results into the Store by calling the deleteNote mutation.

The deleteNote mutation is defined as follows:

deleteNote (state, { id }) {
    if (id) {
        state.notesList = state.notesList.filter(n => n.id !== id)
    }

    state.note = null
},
Enter fullscreen mode Exit fullscreen mode

The deleteNote mutation filters out the deleted note from the state.notesList, and resets the existing state.note object.

The last action defined by the Store is the saveNote action that’s defined as:

async saveNote ({ commit, state }) {
    let note = {}

    let url = state.note.id ? `/api/notes/${state.note.id}` : '/api/notes'
    let method = state.note.id ? 'patch' : 'post'

    await axios({
        method,
        url,
        data: state.note
    }).then(response => {
        note = response.data.note
    })

    commit('saveNote', note)
}
Enter fullscreen mode Exit fullscreen mode

This action checks if the currently edited note is an existing note or a new one. Accordingly, it sends a POST or PATCH request to the backend server to either create the new note or update an existing one. Finally, it commits the results into the Store by calling the saveNote mutation.

The saveNote mutation is defined as follows:

saveNote (state, note) {
    const notePosition = state.notesList.findIndex(n => n.id === note.id)

    if (notePosition < 0) {
        state.notesList.push(note)
    } else {
        state.notesList.splice(notePosition, 1, note)
    }

    state.note = null
}
Enter fullscreen mode Exit fullscreen mode

The mutation above adds, or amends, a note inside the state.notesList to properly sync with the backend database.

Finally, the Store defines two getters to allow components to retrieve data from the state as:

getters: {
    notes: state => state.notesList,
    currentNote: state => state.note
},
Enter fullscreen mode Exit fullscreen mode

That’s all that we have for the Store for the time being. Let’s see how the components make use of this Store in the next section.

Integrate the Store in the app

The components NoteItem, NotesList, and NotesCreate communicate with the Notes component via the this.$emit() event bus. And in turn, the Notes component communicates with the Store via Getters and Actions.

The Notes component defines the following code:

computed: {
    ...mapGetters(['notes', 'currentNote'])
},
mounted () {
    this.getNotesList()
},
methods: {
    ...mapActions(['getNotesList', 'saveNote', 'setNote', 'deleteNote'])
}
Enter fullscreen mode Exit fullscreen mode

Inside the computed property, it imports the notes and currentNote Store getters. These getters are now available as computed properties inside this component.
Inside the methods section, it imports the Store actions: getNotesList, saveNote, setNote, and deleteNote. These actions become methods defined in this component, and can be called like any other method defined.

The mounted lifecycle hook calls the getNotesList() action to retrieve the list of notes from the backend API, and fill the Store with the data.

<NotesCreate
    :note="currentNote"
    @save-note="saveNote"
    @set-note="setNote"
    @delete-note="deleteNote"
    class="notes-container__create"
></NotesCreate>
Enter fullscreen mode Exit fullscreen mode

Notice how the events emitted by the NotesCreate component are bound directly to the methods (actions mapped to method names inside the component). When the user clicks the Save button, the action method saveNote is triggered automatically inside the Store.

The same applies to the rest of the actions in this component.

Run the app

Run the following command and start playing around with the app:

npm run serve

The app runs on the port 8080, and can be accessed by visiting the following URL: http://localhost:8080.

Start typing text inside the editor. You can use Markdown text. Notice how the compiled Markdown text is displayed on the right side of the editor. You may click the Save button to create a new note, or clear the editor. You can also click an existing note to load its content inside the editor.

Conclusion

This article demonstrates how you can continue working on the front-end side of your application while the backend is being built, especially during the early stages of development.

In future installments, I will be showing you how to integrate this app with Google Firebase to store the data, and authenticate users. You will be able to login to the app and manage your own set of notes.

💖 💪 🙅 🚩
bhaidar
Bilal Haidar

Posted on January 7, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related