Vue

Learn how to build an Offline-First application in Vue with Hoodie and Workbox. You will learn about Offline-First, Service Workers, and a few caching strategies.

Offline-First is an approach to software development where a lack of network connection is not treated as an error. You start by developing the application to work in areas with no internet connection. Then, as users enter areas with network connection or as their connection speed improves, the application is progressively enhanced to make more functionality available in the app. For this tutorial, we want to be able to add and delete data when users are either offline or online. This is where Hoodie will help out.

Hoodie is a JavaScript Backend for Offline-First web applications. It provides a frontend API to allow you to store and manage data and add user authentication. It stores data locally on the device and, when there’s a network connection, syncs data to the server and resolves any data conflicts. It uses PouchDB on the client, and CouchDB and hapi for the server. We’ll use it both for user authentication as well as storing the shopping items.

We will build the example application with Vue.js and a Service Worker, which will be generated with workbox. Here’s a preview of what we’ll be building:

shopping list app

Development Setup

To set up your environment, clone the files on https://github.com/pmbanugo/shopping-list-vue-starter. Clone and install the project dependencies by running the following commands in your command-line:

git clone https://github.com/pmbanugo/shopping-list-vue-starter.git
cd shopping-list-starter-vue/
npm install

The dependencies installed are Hoodie and Workbox CLI. The package.json file should look similar to this:

{
  "name": "shopping-list",
  "version": "1.0.0",
  "description": "",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "hoodie"
  },
  "license": "ISC",
  "dependencies": {
    "hoodie": "28.2.2"
  },
  "devDependencies": {
    "workbox-cli": "3.6.2"
  }
}

Running npm start starts the Hoodie backend and tells you the URL to access it. By default that is http://127.0.0.1:8080. The files contained in the public directory are the pages and CSS files needed to render a nice-looking UI. All assets in the public folder, like images, CSS files, or JavaScript files, will be served by the Hoodie Backend on http://127.0.0.1:8080/<path-to-your-file.ext>.

Adding Shared Components

We’re going to have two pages – home and history.

home page

history page

These pages will share the same navigation header and authentication components. For this reason, add a file shared.js in the js folder with the following content:

Vue.component("register-dialog", {
  data: function() {
    return {
      username: "",
      password: ""
    };
  },
  props: ["toggleLoggedIn"],
  template: `<dialog id="register-dialog" class="mdl-dialog">
      <h4 class="mdl-dialog__title">Register</h4>
      <div class="mdl-dialog__content">
        <div class="mdl-grid center-items">

          <div class="mdl-textfield mdl-js-textfield">
            <input v-model="username" class="mdl-textfield__input" type="text" id="register-username">
            <label class="mdl-textfield__label" for="register-username">Username</label>
          </div>
        </div>
        <div class="mdl-grid center-items">

          <div class="mdl-textfield mdl-js-textfield">
            <input v-model="password" class="mdl-textfield__input" type="password" id="register-password">
            <label class="mdl-textfield__label" for="register-password">Password</label>
          </div>
        </div>
        <div class="mdl-grid center-items">

          <div class="mdl-textfield mdl-js-textfield">
            <span id="register-error"></span>
          </div>
        </div>
      </div>
      <div class="mdl-dialog__actions">
        <button @click="closeRegister" type="button" class="mdl-button close">Cancel</button>
        <button @click="register" type="button" class="mdl-button">Register</button>
      </div>
    </dialog>`,
  methods: {
    closeRegister: function() {
      const registerDialog = document.querySelector("#register-dialog");
      dialogPolyfill.registerDialog(registerDialog);
      registerDialog.close();
    },
    register: function() {
      let options = { username: this.username, password: this.password };

      hoodie.account
        .signUp(options)
        .then(account => {
          return hoodie.account.signIn(options);
        })
        .then(account => {
          this.toggleLoggedIn();
          this.closeRegister();
          return account;
       })
        .catch(error => {
          console.log(error);
          document.querySelector("#register-error").innerHTML =
            "Error occurred on Registration";
        });
    }
  }
});

The code above registers a register-dialog component. We have a register() function, which calls hoodie.account.signUp() to register a new user. Hoodie’s account API lets you do user authentication, such as registering new users and signing them in and out. The hoodie object is available to use because we will add a script reference to Hoodie library later on our pages.

Add the following code to the same file for a login and navigation component:

Vue.component("navigation", {
  props: ["isLoggedIn", "toggleLoggedIn"],
  template: `<div>
            <header class="mdl-layout__header">
        <div class="mdl-layout__header-row">
          <!-- Title -->
          <span class="mdl-layout-title">Shopping List</span>
          <!-- Add spacer, to align navigation to the right -->
          <div class="mdl-layout-spacer"></div>
          <!-- Navigation. We hide it in small screens. -->
          <nav class="mdl-navigation mdl-layout--large-screen-only">
            <a class="mdl-navigation__link" href="index.html">Home</a>
            <a class="mdl-navigation__link" href="history.html">History</a>
            <a v-show="!isLoggedIn" @click="showLogin" style="cursor: pointer" class="mdl-navigation__link login">Login</a>
            <a v-show="!isLoggedIn" @click="showRegister" style="cursor: pointer" class="mdl-navigation__link register">Register</a>
            <a v-show="isLoggedIn" @click="logout" style="cursor: pointer" class="mdl-navigation__link logout">Logout</a>
          </nav>
        </div>
      </header>
      <div class="mdl-layout__drawer">
        <span class="mdl-layout-title">Shopping List</span>
        <nav class="mdl-navigation">
          <a class="mdl-navigation__link" href="index.html">Home</a>
          <a class="mdl-navigation__link" href="history.html">History</a>
          <a v-show="!isLoggedIn" @click="showLogin" style="cursor: pointer" class="mdl-navigation__link login">Login</a>
          <a v-show="!isLoggedIn" @click="showRegister" style="cursor: pointer" class="mdl-navigation__link register">Register</a>
          <a v-show="isLoggedIn" @click="logout" style="cursor: pointer" class="mdl-navigation__link logout">Logout</a>
        </nav>
      </div>
            </div>`,

  methods: {
    showLogin: function() {
      const loginDialog = document.querySelector("#login-dialog");
      dialogPolyfill.registerDialog(loginDialog);
      loginDialog.showModal();
    },
    showRegister: function() {
      const registerDialog = document.querySelector("#register-dialog");
      dialogPolyfill.registerDialog(registerDialog);
      registerDialog.showModal();
    },
    logout: function() {
      hoodie.account
        .signOut()
        .then(() => {
          this.toggleLoggedIn();
          window.location.reload();
        })
        .catch(error => {
          alert("Could not logout");
        });
    }
  }
});

Vue.component("login-dialog", {
  data: function() {
    return {
      username: "",
      password: ""
    };
  },
  props: ["toggleLoggedIn"],
  template: `<dialog id="login-dialog" class="mdl-dialog">
      <h4 class="mdl-dialog__title">Login</h4>
      <div class="mdl-dialog__content">
        <div class="mdl-grid center-items">
          <!-- Simple Textfield -->
          <div class="mdl-textfield mdl-js-textfield">
            <input v-model="username" class="mdl-textfield__input" type="text" id="login-username">
            <label class="mdl-textfield__label" for="login-username">Username</label>
          </div>
        </div>
        <div class="mdl-grid center-items">
          <!-- Simple Textfield -->
          <div class="mdl-textfield mdl-js-textfield">
            <input v-model="password" class="mdl-textfield__input" type="password" id="login-password">
            <label class="mdl-textfield__label" for="login-password">Password</label>
          </div>
        </div>
        <div class="mdl-grid center-items">
          <!-- Simple Textfield -->
          <div class="mdl-textfield mdl-js-textfield">
            <span id="login-error"></span>
          </div>
        </div>
      </div>
      <div class="mdl-dialog__actions">
        <button @click="closeLogin" type="button" class="mdl-button close">Cancel</button>
        <button @click="login" type="button" class="mdl-button">Login</button>
      </div>
    </dialog>`,
  methods: {
    closeLogin: function() {
      const loginDialog = document.querySelector("#login-dialog");
      dialogPolyfill.registerDialog(loginDialog);
      loginDialog.close();
    },
    login: function(event) {
      hoodie.account
        .signIn({
          username: this.username,
          password: this.password
        })
        .then(() => {
          this.toggleLoggedIn();
          this.closeLogin();
        })
        .catch(error => {
          console.log(error);
          document.querySelector("#login-error").innerHTML = "Error logging in";
        });
    }
  }
});

Above we have the login-dialog component. It handles login and calls hoodie.account.signIn() to log users in. We also have the navigation component, which creates a navigation header with buttons to trigger the register and login components, and a logout button. The logout button calls the logout() function which handles logging users out by calling hoodie.account.signOut(). With these components in place, we now need to create the actual pages.

Adding, Removing, and Saving Shopping List

The application allows users to add shopping items to their shopping list. We will add a page that allows users to add and remove items, then save the list. Add a file named index.html with the following content:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="utf-8">
  <meta http-equiv="x-ua-compatible" content="ie=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="description" content="easily make a list of your shopping items and track your shopping expense">
  <title>Shopping List</title>

  <link rel="stylesheet" href="/resources/mdl/material-icons.css">
  <link rel="stylesheet" href="/resources/mdl/material.indigo-pink.min.css" />
  <link rel="stylesheet" href="/css/style.css" />
  <script src="/resources/mdl/material.min.js"></script>
  <script src="/resources/dialog-polyfill/dialog-polyfill.js"></script>
  <link rel="stylesheet" href="/resources/dialog-polyfill/dialog-polyfill.css" />
</head>

<body>
  <div id="app">
    <div class="mdl-layout mdl-js-layout mdl-layout--fixed-header">

      <navigation v-bind:is-logged-in="isLoggedIn" v-bind:toggle-logged-in="toggleLoggedIn"></navigation>
      <main class="mdl-layout__content">
        <div class="page-content">
          <div class="center">
            <h2>List</h2>
          </div>

          <div>
            <form v-on:submit.prevent="onSubmit">
              <div class="mdl-grid center-items">
                <div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label">
                  <input class="mdl-textfield__input" type="text" id="new-item-name" v-model="name">
                  <label class="mdl-textfield__label" for="new-item-name">Item Name</label>
                </div>
                <div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label">
                  <input class="mdl-textfield__input" type="number" id="new-item-cost" v-model="cost">
                  <label class="mdl-textfield__label" for="new-item-cost">Item Cost</label>
                </div>
                <div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label">
                  <input class="mdl-textfield__input" type="number" id="new-item-quantity" v-model="quantity">
                  <label class="mdl-textfield__label" for="new-item-quantity">Quantity</label>
                </div>
            </div>

            <div class="mdl-grid center-items">
                <button id="add-item" class="mdl-button mdl-js-button mdl-button--raised mdl-button--colored">
                  Add Item
                </button>
            </div>
            </form>
          </div>

          <div class="mdl-grid center-items">
            <table id="item-table" class="mdl-data-table mdl-js-data-table mdl-shadow--2dp">
            <thead>
                <tr>
                  <th class="mdl-data-table__cell--non-numeric">Item Name</th>
                  <th class="mdl-data-table__cell--non-numeric">Cost</th>
                  <th class="mdl-data-table__cell--non-numeric">Quantity</th>
                  <th class="mdl-data-table__cell">Sub-total</th>
                  <th class="mdl-data-table__cell--non-numeric">
                    <button class="mdl-button mdl-js-button mdl-button--icon">
                      <i class="material-icons">delete</i>
                    </button>
                  </th>
                </tr>

            </thead>
            <tbody>
                <tr v-for="item in items" :key="item._id">
                  <td class="mdl-data-table__cell--non-numeric">{{ item.name}}</td>
                  <td class="mdl-data-table__cell--non-numeric">{{ item.cost}}</td>
                 <td class="mdl-data-table__cell--non-numeric">{{ item.quantity}}</td>
                  <td class="mdl-data-table__cell">{{ item.subTotal}}</td>
                  <td class="mdl-data-table__cell--non-numeric">
                    <button @click="deleteRow(item._id)" class="mdl-button mdl-js-button mdl-button--icon mdl-button--colored">
                      <i class="material-icons">remove</i>
                    </button>
                  </td>
                </tr>
            </tbody>
            </table>
          </div>

          <div class="mdl-grid center-items">
            <div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label">
            <h4>Total Cost: {{ total }}</h4>
            </div>

          </div>

          <div class="mdl-grid center-items">
            <button @click="saveList" class="mdl-button mdl-js-button mdl-button--raised mdl-button--colored">
            Save List
            </button>
          </div>

          <div class="mdl-grid center-items">
            <div id="toast" class="mdl-js-snackbar mdl-snackbar">
            <div class="mdl-snackbar__text"></div>
            <button class="mdl-snackbar__action" type="button"></button>
            </div>
          </div>

        </div>
      </main>
    </div>

    <login-dialog v-bind:toggle-logged-in="toggleLoggedIn"></login-dialog>
    <register-dialog v-bind:toggle-logged-in="toggleLoggedIn">
    </register-dialog>

  </div>

  <script src="/hoodie/client.js"></script>
  <script src="resources/vue@2.5.16.js"></script>
  <script src="js/shared.js"></script>
  <script src="js/index.js"></script>
</body>

</html>

This file contains markup for adding, removing, and saving a shopping list. At the bottom, we added a reference to Hoodie client, Vue.js, shared.js file we added earlier, and index.js we will add soon. The Hoodie client will be served by the Hoodie server once the app starts. The actual file can be found in .hoodie/client.jsin the root project directory.

Next, we add the file index.js with the content of the file as:

const vm = new Vue({
  el: "#app",
  data: {
    name: "",
    cost: "",
    quantity: "",
    items: [],
    isLoggedIn: false
  },
  computed: {
    total: function() {
      return this.items.reduce(
        (accumulator, currentValue) => accumulator + currentValue.subTotal,
        0
      );
    }
  },
  methods: {
    toggleLoggedIn: function() {
      this.isLoggedIn = !this.isLoggedIn;
    },
    onSubmit: function(event) {
      if (this.name && this.cost && this.quantity) {
        hoodie.store.withIdPrefix("item").add({
          name: this.name,
          cost: this.cost,
          quantity: this.quantity,
          subTotal: this.cost * this.quantity
        });

        this.name = "";
        this.cost = "";
        this.quantity = "";
      } else {
        const snackbarContainer = document.querySelector("#toast");
        snackbarContainer.MaterialSnackbar.showSnackbar({
          message: "All fields are required"
        });
      }
    }
  },
  created() {
    hoodie.store.withIdPrefix("item").on("add", item => vm.items.push(item));

    //retrieve items on the current list
    hoodie.store
      .withIdPrefix("item")
      .findAll()
      .then(items => (vm.items = items));

    hoodie.account.get("session").then(function(session) {
      if (!session) {
        // user is singed out
        vm.isLoggedIn = false;
      } else if (session.invalid) {
        vm.isLoggedIn = false;
      } else {
        // user is signed in
        vm.isLoggedIn = true;
      }
    });
  }
});

In the code above, we have initialized a Vue instance. It has data values to hold state values, a computed property to get the total cost on the list, the created lifecycle hook, and some functions in the methodsproperty. The onSubmit function saves the item to Hoodie by calling hoodie.store.withIdPrefix("item").add(..). This is the Hoodie store API, which provides means to store and retrieve data for each individual user. You can call hoodie.store.add() to store data, but we’ve used hoodie.store.withIdPrefix("item") as a way to store items on a separate container, and later we’ll use the same approach to store the saved shopping list data on a separate container. When Hoodie stores this data, it’ll trigger an add event, and if the user is logged in to other devices, it’ll synchronize and trigger the same event. This event is handled on line 41. Lines 44 to 47 load the data when the page loads, while lines 49 to 58 check if the user is logged in.

In order to remove saved items or save the items as a list, we’ll add functions to remove an item and another to save items as a list. Add the following code as an addition to the existing methods option of the Vue instance.

//line 38

    deleteRow: function(itemId) {
      hoodie.store.withIdPrefix("item").remove(itemId);
    },

    saveList: function() {
      hoodie.store
        .withIdPrefix("item")
        .findAll()
        .then(items => {
          //store the list
          hoodie.store.withIdPrefix("list").add({
            cost: this.total,
            items: items
          });

          //delete the items
          hoodie.store
            .withIdPrefix("item")
            .remove(items)
            .then(() => {
            //clear the table
            this.items = [];

            //notify the user
            var snackbarContainer = document.querySelector("#toast");
            snackbarContainer.MaterialSnackbar.showSnackbar({
                message: "List saved successfully"
            });
            })
            .catch(function(error) {
            //notify the user
            var snackbarContainer = document.querySelector("#toast");
            snackbarContainer.MaterialSnackbar.showSnackbar({
                message: error.message
            });
            });
        });
    }

The deleteRow function removes an item, while saveList saves the items as a list. On the created lifecycle hook method, add the following code to it:

hoodie.store
  .withIdPrefix("item")
  .on(
    "remove",
    deletedItem =>
      (vm.items = vm.items.filter(item => item._id !== deletedItem._id))
  );

This listens for the remove event and updates the state accordingly.

Let’s see what we’ve got so far! Open the command line and run npm start to start the Hoodie server. Open your browser to localhost:8080. Try adding and removing items. Also, register and log in with a user to see data synchronize across browsers/devices as you add and remove items.

hoodie-vue.gif

It also works offline! To test this:

  • Log in with the same user on different browsers
  • Stop the hoodie server (open the command line window where you ran npm start and press Ctrl + C to stop the running process)
  • Open the browsers and add or remove items
  • Start the Hoodie server and watch the data update across browsers

That is the benefit of Offline-First. The applications work even when the server is down or the user lacks connectivity.

Viewing Shopping History

From the previous section, we have code to add and remove items and save items as a list. These saved lists we want to view as the shopping history, with a list of each shopping cost and date. Add a new file history.html in the public folder with the content below:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="utf-8">
  <meta http-equiv="x-ua-compatible" content="ie=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="description" content="easily make a list of your shopping items and track your shopping expense">
  <title>Shopping List</title>

  <link rel="stylesheet" href="/resources/mdl/material-icons.css">
  <link rel="stylesheet" href="/resources/mdl/material.indigo-pink.min.css" />
  <link rel="stylesheet" href="/css/style.css" />
  <script src="/resources/mdl/material.min.js"></script>
  <script src="/resources/dialog-polyfill/dialog-polyfill.js"></script>
  <link rel="stylesheet" href="/resources/dialog-polyfill/dialog-polyfill.css" />
</head>

<body>
  <div id="app">
    <div class="mdl-layout mdl-js-layout mdl-layout--fixed-header">
      <navigation v-bind:is-logged-in="isLoggedIn" v-bind:toggle-logged-in="toggleLoggedIn"></navigation>
      <main class="mdl-layout__content">
        <div class="page-content">
          <div class="center">
            <h2>History</h2>
          </div>

          <div class="mdl-grid center-items">
            <ul id="list-history" class="demo-list-icon mdl-list">
            <li v-for="item in list" :key="item._id" class="mdl-list__item">
                <span class="mdl-list__item-primary-content">
                  <span class="pad-right">{{ new Date(item.hoodie.createdAt).toDateString() }}</span>
                  <span>
                    <span class="cost-label">Cost: </span> ${{ item.cost}}</span>
                </span>
            </li>
            </ul>
          </div>

          <div class="mdl-grid center-items">
            <div id="toast" class="mdl-js-snackbar mdl-snackbar">
            <div class="mdl-snackbar__text"></div>
            <button class="mdl-snackbar__action" type="button"></button>
            </div>
          </div>

        </div>
      </main>
    </div>

    <login-dialog v-bind:toggle-logged-in="toggleLoggedIn"></login-dialog>
    <register-dialog v-bind:toggle-logged-in="toggleLoggedIn"> </register-dialog>
  </div>

  <script src="/hoodie/client.js"></script>
  <script src="resources/vue@2.5.16.js"></script>
  <script src="js/shared.js"></script>
  <script src="js/history.js"></script>
</body>

</html>

In the code above, lines 30 to 38 loop through the saved list and display the appropriate content. Add a new file history.js in the js folder.

const vm = new Vue({
  el: "#app",
  data: {
    list: [],
    isLoggedIn: false
  },
  methods: {
    toggleLoggedIn: function() {
      this.isLoggedIn = !this.isLoggedIn;
    }
  },
  created() {
    hoodie.store
      .withIdPrefix("list")
      .findAll()
      .then(savedList => (vm.list = savedList));

    hoodie.account.get("session").then(function(session) {
      if (!session) {
        // user is singed out
        vm.isLoggedIn = false;
      } else if (session.invalid) {
        vm.isLoggedIn = false;
      } else {
        // user is signed in
        vm.isLoggedIn = true;
      }
    });
  }
});

The code above gets the whole saved list from Hoodie store and sets the list state with the result. Open your browser and navigate to the history page.

history page.png

We now have the complete application storing and retrieving data even in offline scenarios! But, when we open the app or navigate to a different page while offline, the page will not load. Wouldn’t it be nice to also load pages offline? We’ll make this possible using Service Workers.

Adding Service Workers

Service Worker is a programmable network proxy that runs on a separate browser thread and allows you to intercept network requests and process them as you so choose. You can intercept and cache a response from the server, and, the next time the app makes a request for that resource, you can send the cached version. It runs regardless of whether the page is currently open or not.

We’re going to add a Service Worker script which will intercept all network requests and respond with a cached version if the resource refers to our page and its related assets. This resource will be cached using the Cache API.

The Cache API, which is part of the Service Worker specification, enables Service Workers to cache network requests so that they can provide appropriate responses even while offline.

We will generate a Service Worker script using Workbox. Workbox is a set of Service Worker libraries that makes building progressive web apps easy. We will use the Workbox CLI to generate this script so we don’t have to write it from scratch. We installed the Workbox CLI when we installed the dependencies from the starter project. We will need a configuration file to instruct the CLI what to include in the script it’ll generate. Add a new file workbox-config.js in the root directory of the project with this content:

module.exports = {
  globDirectory: "public/",
  globPatterns: ["**/*.{css,ico,html,png,js,json,woff2}"],
  swDest: "./public/sw.js",
  skipWaiting: true,
  clientsClaim: true,
  templatedUrls: {
    "/hoodie/client.js": ".hoodie/cleint.js"
  }
};

The globDirectory tells it which directory it should pick files from and globPatterns dictates the type of files to cache. The swDest option tells it where to store the generated script; templatedUrls tells it where to pick the Hoodie script to cache; then skipWaiting and clientsClaim are set to true because we want to be able to publish a new Service Worker and have it update and control a web page as soon as possible, skipping the default Service Worker lifecycle. To learn more about these configuration options, check out the docs.

Open the command line and run workbox generateSW. This should generate a file sw.js in public folder. Open shared.js and add the following code at the top of the file

if ("serviceWorker" in navigator) {
  navigator.serviceWorker
    .register("sw.js")
    .then(console.log)
    .catch(console.error);
}

This checks if the browser supports Service Workers. If it does, it registers the file as the Service Worker script, allowing it to take control of the page and to be able to intercept network requests. Start the Hoodie server and open the application. It should register the Service Worker and show something like this in the console:

Screenshot 2018-10-15 at 10.27.42.png

When you navigate to another page, it should load files from the Cache.

Screenshot 2018-10-15 at 10.28.25.png

That’s A Wrap!

We’ve built an Offline-First Vue application. We built it with Hoodie and Workbox. We used the authentication API to manage authentication for the app, and the store API to store and retrieve data. We saw how it handled the data both offline and online. With Workbox, we easily generated a Service Worker script to precache the application’s assets so it can load offline. You can find the completed application source on GitHub.


For more Vue info: Want to learn about creating great user interfaces with Vue? Check out Kendo UI for Vue with everything from grids and charts to schedulers and pickers, and don't forget to check out this other great Vue content:


Peter Mbanugo
About the Author

Peter Mbanugo

Peter Mbanugo is a software developer, tech writer, and maker of Hamoni Sync. He currently works with Field Intelligence, where he helps build logistic and supply chain apps. He's also a contributor to Hoodie and a member of the Offline-First community. You can follow him on Twitter.

Related Posts

Comments

Comments are disabled in preview mode.