Building a TodoMVC with Twilio Sync and JavaScript

September 28, 2017
Written by

icpvM7iV5ZfjEqqS0XzipFTaeSJxcohcgg5eWCcyEsuKdWBiTqraS2VwI5qW-Ev_cgbnkzdlL5kgByrYabGYJpKo1V0iDclKIzr3eZpWme2Tqms3qhL_bXunccJjIFsffXlMqFYo

Sharing information between different devices in real-time is difficult but with Twilio Sync it’s just a matter of a few lines of code. To see how Sync works let’s create our own version of the TodoMVC application using Twilio Sync to store our data and share it across multiple devices.

We’ll base our version on a copy of the Vanilla JS version of TodoMVC and alter the storage that is currently local storage to Twilio Sync. However, you can use the same code with your favorite framework as well.

Setup

Before we get started make sure that you got the following things:

Start by cloning the template branch of this repository and install the dependencies:

git clone -b template https://github.com/dkundel/todomvc-twilio-sync.git
cd todomvc-twilio-sync
npm install

Fill out the .env file with the necessary values. If you don’t have a Twilio API Key / Secret yet, make sure to generate them in the Twilio Console. Afterwards create a Twilio Sync Service in the Console and copy the SID into the .env file.

Check if your server starts up by running:

npm start

Navigate to http://localhost:3000 in your browser and you should see the basic TodoMVC app.

Authentication

The Twilio Sync SDK requires a token when we initialize the client. Think of the token like an “access card” for the respective user. It defines who the user is and what permissions they  have. This token has to be generated on the server-side. We’ll create a new route on our server that will serve us such an access token.

Open the index.js file on your project root and add the following lines above the app.listen(...) statement:


app.get('/token', (req, res) => {
 const ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID;
 const SERVICE_SID = process.env.TWILIO_SYNC_SERVICE;
 const API_KEY = process.env.TWILIO_API_KEY;
 const API_SECRET = process.env.TWILIO_API_SECRET;

 const IDENTITY = 'only for testing';

 const AccessToken = Twilio.jwt.AccessToken;
 const SyncGrant = AccessToken.SyncGrant;

 const syncGrant = new SyncGrant({
   serviceSid: SERVICE_SID
 });

 const accessToken = new AccessToken(ACCOUNT_SID, API_KEY, API_SECRET);

 accessToken.addGrant(syncGrant);
 accessToken.identity = IDENTITY;

 res.send({ token: accessToken.toJwt() });
});

app.listen(PORT, () => {

This code will always create a token with the same identity. This setup is fine for now since we won’t handle authentication and want to just share the to-do list with everyone. This would be your first step to permissions though.

Restart the server and go to http://localhost:3000/token and you should get back a JSON that looks similar to this:

access-token.png

From LocalStorage to Sync

The TodoMVC app is currently uses LocalStorage to store items and we’re going to update it to use Sync. Start by changing in our index.html the script that is being consumed. Find the script tag that includes "js/local-storage.js" and replace it with "js/sync-storage.js".

In the same place we are also consuming the Twilio Sync SDK from the Twilio CDN. If you are using a tool like Webpack for your project, no worries, you can also consume the SDK from npm.

Open the sync-storage.js file. It currently contains all the necessary functions that we need to implement but with basically no logic. We need to implement the following functions to get it working:

  • Store(...) ⇒ An initialization function
  • Store.prototype.find() ⇒ Searches for certain to-dos based on a filter
  • Store.prototype.findAll() ⇒ Returns all to-dos
  • Store.prototype.save() ⇒ Creates or updates a to-do
  • Store.prototype.remove() ⇒ Removes a certain to-do
  • Store.prototype.drop() ⇒ Deletes all to-dos

Before we get started with the code, let’s talk about a bit of Twilio Sync jargon.

There are three types of data that we can store in Twilio Sync. A document, a map and a list:

  • A document is an object that can have a bunch of properties. This is great for things like a configuration object.
  • A map is an unordered collection that has keys for every object to identify that respective object
  • A list is an ordered collection where the keys are defined as “index”. Think of it like an array. We’ll be using this data type for our to-do list.

Initializing Twilio Sync and a list

First we need to initialize our storage. For this we need to perform the following steps:

  1. Fetch a token using fetch('/token')
  2. Create a client instance
  3. Get the list with the name passed to the function
  4. Retrieve the current set of items on the list
  5. Call the callback with the items

Place the following lines into the Storage function:


  function Store(name, callback, eventHandler) {
    callback = callback || function() {};

    this._dbName = name;

    // START SYNC
    fetch('/token')
      .then(response => {
        if (!response.ok) {
          throw new Error('Could not retrieve token');
        }
        return response.json();
      })
      .then(data => {
        this._client = new Twilio.Sync.Client(data.token);
        return this._client.list(name);
      })
      .then(list => {
        this._list = list;
        return list.getItems();
      })
      .then(todos => {
        callback.call(this, { todos: todos.items.map(extractData) });
      })
      .catch(err => {
        console.error(err);
      });
  }

As part of this we are saving the list instance of the list we created as this._list. With this instance we will interact within the functions to follow.

Additionally the data of every item in the SyncList is wrapped with some meta-data. We will have to extract the actual data. Add the following extractData function at the bottom of the file:


  function extractData(todo) {
    return todo.data.value;
  }

  // Export to window
  window.app = window.app || {};
  window.app.Store = Store;

Finding items

There are two ways of finding to-do items that we need to implement for this storage. One is the find function and one is findAll. Both return lists of items to a callback function, but find allows you to filter those items with a query object. To implement findAll we can use the getItems() method on the this._list instance to retrieve all to-dos and map over them using the extractData function. Update the findAll function accordingly:


  Store.prototype.findAll = function(callback) {
    callback = callback || function() {};

    this._list.getItems().then(todos => {
      callback.call(this, todos.items.map(extractData));
    });
  };

SyncLists don’t have a filter function themselves, so to build the find method we will fetch all the items and then filter them ourselves. For that add the following createFilterFunction function in the bottom of your file:


  function createFilterFunction(query) {
    return function(todo) {
      for (let q in query) {
        if (query[q] !== todo[q]) {
          return false;
        }
      }
      return true;
    };
  }

  // Export to window
  window.app = window.app || {};
  window.app.Store = Store;

Now use the same getItems() method as we used in the findAll function and and filter the results using the createFilterFunction. Change your find function to the following:


  Store.prototype.find = function(query, callback) {
    callback = callback || function() {};

    this._list.getItems().then(todos => {
      callback.call(
        this,
        todos.items.map(extractData).filter(createFilterFunction(query))
      );
    });
  };

Inserting and updating items

Next we need to implement the save() function that both creates and updates items. If the function gets passed an id it’s as easy as calling .update(id, data) on the list to update the data. TodoMVC also expects the list of current todos to be returned so we also need to fetch all the items again and call the callback with it.

Add the following code to the save() function:


  Store.prototype.save = function(updateData, callback, id) {
    callback = callback || function() {};

    if (typeof id !== 'undefined') {
      this._list
        .update(id, updateData)
        .then(item => {
          return this._list.getItems();
        })
        .then(todos => {
          callback.call(this, todos.items.map(extractData));
        });
    }
  };

Creating a new item is a bit more complex. We will only know the id which will be the index of the item once it has actually been added to the list. So first we will add the item and afterwards we will update it with the id being the index.

Extend the save() method accordingly:


  Store.prototype.save = function(updateData, callback, id) {
    callback = callback || function() {};

    if (typeof id !== 'undefined') {
      this._list
        .update(id, updateData)
        .then(item => {
          return this._list.getItems();
        })
        .then(todos => {
          callback.call(this, todos.items.map(extractData));
        });
    } else {
      this._list.push(updateData).then(item => {
        updateData.id = item.index;
        this._list.update(updateData.id, { id: updateData.id }).then(() => {
          callback.call(this, [updateData]);
        });
      });
    }
  };

Let’s test our code for the first time! Go to http://localhost:3000 and start creating items. If you refresh the window you will see the data is still there.

Removing items and deleting them all

The two methods that we still have left to implement are remove and drop. Removing is straightforward. Call remove() with the respective id and the item is gone.

Add the following code to the remove() method:


  Store.prototype.remove = function(id, callback) {
    callback = callback || function() {};
    this._list
      .remove(id)
      .then(() => {
        return this._list.getItems();
      })
      .then(todos => {
        callback.call(this, todos.items.map(extractData));
      });
  };

To empty the list we will have to delete the actual list and re-create it. To do so add the following code:


  Store.prototype.drop = function(callback) {
    callback = callback || function() {};

    this._list
      .removeList()
      .then(() => {
        this._client.list(this._dbName);
      })
      .then(list => {
        this._list = list;
        return this._list.getItems();
      })
      .then(todos => {
        callback.call(this, todos.items.map(extractData));
      });
  };

You are now able to delete items from your to-do list. Feel free to test this by reloading the page in your browser and try to delete some of the items.

Time for sync magic!

So far we’ve been using Sync mainly as a database to store the items in but we haven’t actually listened for changes. SyncLists expose a series of different events. We will be listening on itemAdded, itemUdpated and itemRemoved and call the eventHandler() function passed to Store().

Add the following code to the Store constructor:


  function Store(name, callback, eventHandler) {
    callback = callback || function() {};

    this._dbName = name;

    // START SYNC
    fetch('/token')
      .then(response => {
        if (!response.ok) {
          throw new Error('Could not retrieve token');
        }
        return response.json();
      })
      .then(data => {
        this._client = new Twilio.Sync.Client(data.token);
        return this._client.list(name);
      })
      .then(list => {
        this._list = list;
        this._list.on('itemAdded', () => {
          eventHandler();
        });
        this._list.on('itemUpdated', () => {
          eventHandler();
        });
        this._list.on('itemRemoved', () => {
          eventHandler();
        });
        return list.getItems();
      })
      .then(todos => {
        callback.call(this, { todos: todos.items.map(extractData) });
      })
      .catch(err => {
        console.error(err);
      });
  }

In the app.js we are already passing a function where we will call todo.controller._filter(true); on line 30. This will trigger a reload of the controller and will display the updated list.

Reload http://localhost:3000 and put two windows side by side. Add some items, remove some, check some off. You will see the lists magically update on both sides at the same time!

sync-todo.gif

Just the beginning

Sync is very powerful and this is simply scratching the surface of what you can do. If you want to learn more check out some of the following resources:

I would love to see what you come up with using Twilio Sync! Feel free to hit me up at: