$ mkdir twilio-sync-kanban $ cd twilio-sync-kanban
$ mkdir back $ mkdir front
$ cd back $ python -m venv venv $ source venv/bin/activate (venv) $ pip install twilio flask python-dotenv flask-cors faker
$ cd back $ python -m venv venv $ venv\Scripts\activate (venv) $ pip install twilio flask python-dotenv flask-cors faker
pip
, the Python package installer, to install the Python packages that you are going to use in this project, which are:$ pip freeze > requirements.txt
TWILIO_ACCOUNT_SID=<your-twilio-account-sid>
TWILIO_ACCOUNT_SID=<your-twilio-account-sid> TWILIO_API_KEY=<your-twilio-api-key-sid> TWILIO_API_SECRET=<your-twilio-api-key-secret>
+
sign to create a new service. Give it the name kanban or something similar. The next page is going to show you some information about the new service, including the “Service SID”. Copy this value to your clipboard, go back to your .env file and add a fourth line for it:TWILIO_ACCOUNT_SID=<your-twilio-account-sid> TWILIO_API_KEY=<your-twilio-api-key-sid> TWILIO_API_SECRET=<your-twilio-api-key-secret> TWILIO_SYNC_SERVICE_SID=<your-twilio-sync-service-sid-here>
import os from dotenv import load_dotenv from faker import Faker from flask import Flask, jsonify from flask_cors import CORS from twilio.jwt.access_token import AccessToken from twilio.jwt.access_token.grants import SyncGrant dotenv_path = os.path.join(os.path.dirname(__file__), ".env") load_dotenv(dotenv_path) fake = Faker() app = Flask(__name__) CORS(app) @app.route("/token") def randomToken(): identity = fake.user_name() token = AccessToken( os.environ["TWILIO_ACCOUNT_SID"], os.environ["TWILIO_API_KEY"], os.environ["TWILIO_API_SECRET"], ) token.identity = identity sync_grant = SyncGrant(service_sid=os.environ["TWILIO_SYNC_SERVICE_SID"]) token.add_grant(sync_grant) token = token.to_jwt().decode("utf-8") return jsonify(identity=identity, token=token) if __name__ == "__main__": app.run(debug=True, host="0.0.0.0", port=5001)
faker
package is also initialized.SyncGrant
configured with the Sync service created earlier.faker
package is used to generate a random username that is added to the token as the user identity.$ python app.py
* Serving Flask app "app" (lazy loading) * Environment: production WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. * Debug mode: on * Running on http://0.0.0.0:5001/ (Press CTRL+C to quit) * Restarting with stat * Debugger is active! * Debugger PIN: 116-844-499
$ cd twilio-sync-kanban/front
<!doctype html> <html> <head> <title>Twilio Sync Kanban</title> <meta charset="utf-8"> <script type="text/javascript" src="https://media.twiliocdn.com/sdk/js/sync/v1.0/twilio-sync.min.js"></script> </head> <body> <script> let syncClient const setupTwilioClient = async () => { try { const response = await fetch('http://localhost:5001/token') const responseJson = await response.json() const token = responseJson.token syncClient = new Twilio.Sync.Client(token) } catch (e) { console.log(e) } syncClient.on('connectionStateChanged', state => { if (state != 'connected') { console.log(`Sync is not live (websocket connection ${state})`) } else { console.log('Sync is live!') } }) } window.onload = setupTwilioClient </script> </body> </html>
window.onload
event).syncClient
instance.Sync is live!
setupTwilioClient()
function: const setupTwilioClient = async () => { try { const response = await fetch('http://localhost:5001/token') const responseJson = await response.json() const token = responseJson.token syncClient = new Twilio.Sync.Client(token) } catch (e) { console.log(e) } syncClient.on('connectionStateChanged', state => { if (state != 'connected') { console.log(`Sync is not live (websocket connection ${state})`) } else { console.log('Sync is live!') } }) const tasks = await syncClient.list('tasks') const items = await tasks.getItems() console.log(items) }
tasks
, and the second obtains the list of items stored in it.e {prevToken: null, nextToken: null, items: Array(0), source: ƒ} items: [] nextToken: null prevToken: null source: ƒ (e) hasNextPage: (...) hasPrevPage: (...) __proto__: Object
const tasks = await syncClient.list('tasks') await tasks.push({name: 'buy milk'}) await tasks.push({name: 'write blog post'}) const items = await tasks.getItems() console.log(items)
items
attribute it should look like this:items: Array(2) 0: e data: dateExpires: null dateUpdated: ... index: 0 lastEventId: 0 revision: "0" uri: "https://cds.us1.twilio.com/v3/Services/<service_id>/Lists/<list_id>/Items/0" value: {name: "buy milk"} 0: e data: dateExpires: null dateUpdated: ... index: 0 lastEventId: 0 revision: "0" uri: "https://cds.us1.twilio.com/v3/Services/<service_id>/Lists/<list_id>/Items/0" value: {name: "write blog post"}
<div>
element at the start of the <body>
: <body> <div id="tasks"></div> ... </body>
setupTwilioClient()
function to look as follows: const tasks = await syncClient.list('tasks') // await tasks.push({name: 'buy milk'}) // await tasks.push({name: 'write blog post'}) const items = await tasks.getItems() const tasksDiv = document.getElementById('tasks') items.items.forEach(item => { const itemDiv = document.createElement('div') itemDiv.className = "task-item" itemDiv.innerText = item.data.name tasksDiv.appendChild(itemDiv) }) // console.log(items)
task-item
divs, then pop open a cold brew. However, this is not the magic we're after: We want to push()
an item on to the tasks
list and then have it show up on this and any other device which is connected to the application.tasks
div if we add an item to the list after the page was rendered?push()
right after the forEach
render loop: items.items.forEach(item => { ... }) await tasks.push({name: 'figure out how event listeners work'})
push()
added above before you continue.<input>
which does such a push()
call, then work to ensure this update is rendered.<body>
element: <body> <form onsubmit="addTask(event)"> <input id="task-input" type="text" name="task" /> <input type=submit /> </form> ... </body>
addTask()
function that handles the above form submission below setupTwilioClient()
: const addTask = async event => { event.preventDefault() newTaskField = event.target.elements.task const newTask = newTaskField.value console.log(newTask) newTaskField.value = '' const tasks = await syncClient.list('tasks') tasks.push({name: newTask}) }
itemAdded
event, where we can update the list as new items are inserted. Add the following code right after the forEach
loop that renders the task list: tasks.on('itemAdded', item => { const itemDiv = document.createElement('div') itemDiv.className = "task-item" itemDiv.innerText = item.item.data.name tasksDiv.appendChild(itemDiv) })
tasks
list, it will be rendered on the page within a fraction of a second. Go wild!<html> <head> <title>Twilio Sync Kanban</title> <meta charset="utf-8"> <script type="text/javascript" src="https://media.twiliocdn.com/sdk/js/sync/v1.0/twilio-sync.min.js"></script> <style> div { margin: .5em; } span { padding: .5em; } </style> </head> <body> <form onsubmit="addTask(event)"> <input id="task-input" type="text" name="task" /> <input type=submit /> </form> <div id="tasks"></div> <script> let syncClient const setupTwilioClient = async () => { try { const response = await fetch('http://localhost:5001/token') const responseJson = await response.json() const token = responseJson.token syncClient = new Twilio.Sync.Client(token) } catch (e) { console.log(e) } syncClient.on('connectionStateChanged', state => { if (state != 'connected') { console.log(`Sync is not live (websocket connection ${state})`) } else { console.log('Sync is live!') } }) const tasks = await syncClient.list('tasks') const items = await tasks.getItems() const tasksDiv = document.getElementById('tasks') const renderTask = item => { const containerDiv = document.createElement('div') containerDiv.className = "task-item" containerDiv.dataset.index = item.index const itemSpan = document.createElement('span') itemSpan.innerText = item.data.name containerDiv.appendChild(itemSpan) const deleteButton = document.createElement('button') deleteButton.innerText = "delete" deleteButton.addEventListener('click', () => deleteTask(item.index)) containerDiv.appendChild(deleteButton) tasksDiv.appendChild(containerDiv) } items.items.forEach(renderTask) tasks.on('itemAdded', item => renderTask(item.item)) tasks.on('itemRemoved', item => { const itemDiv = document.querySelector(`.task-item[data-index="${item.index}"`) itemDiv.remove() }) } const addTask = async event => { event.preventDefault() newTaskField = event.target.elements.task const newTask = newTaskField.value console.log(newTask) newTaskField.value = '' const tasks = await syncClient.list('tasks') tasks.push({name: newTask}) } const deleteTask = async (index) => { console.log('delete:' + index); const list = await syncClient.list('tasks') list.remove(index) } window.onload = setupTwilioClient </script> </body> </html>
renderTask()
function. This function contains what before was inside the forEach
render loop, extended to add the “Delete” button next to each item. We extract this logic into a function here because we need to use it both a) when we render the initial list and but) when we create a new task through the form. Now that both the render loop and the itemAdded
event handler call this function.<style>
block in the <head>
section of the page.deleteTask()
function with the item index as the argument.index
of each item to it’s container <div>
element as a data
attribute. We reference this attribute in an event handler for the Sync list’s itemRemoved
to specify which element to remove.<html> <head> <title>Twilio Sync Kanban</title> <meta charset="utf-8"> <script type="text/javascript" src="https://media.twiliocdn.com/sdk/js/sync/v1.0/twilio-sync.min.js"></script> <script src="jkanban.min.js"></script> <link rel="stylesheet" href="jkanban.min.css"> <style> form { margin: 1em; } span { padding: .5em; } [data-class=card]:hover { cursor: grab } .dragging { cursor: grabbing !important } </style> </head> <body> ... </body> </html>
<script>
element, instantiate a kanban board instance with three columns labeled “todo”, “doing” and “done”: <script> const board = new jKanban({ element: "#tasks", gutter: "10px", widthBoard: "350px", dragBoards: false, boards: [ {"id": "todo", "title" : "todo"}, {"id": "doing", "title" : "doing"}, {"id": "done", "title" : "done"}, ], }) ... </script>
renderTask()
function to create kanban cards using the addElement()
method of the board. Let’s also change the “Delete” button to a nice icon from Hero Icons: const renderTask = item => { board.addElement("todo", { id: item.index, title: ( `<span>${item.data.name}</span>` + `<svg onclick="deleteTask(${item.index})" xmlns="http://www.w3.org/2000/svg" height=15 width=15 style="cursor: pointer; vertical-align: top;" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> </svg>` ), class: "card", drag: el => el.classList.add('dragging'), dragend: el => el.classList.remove('dragging'), }) }
itemRemoved
event handler to properly remove list items from the kanban board using the removeElement()
method: tasks.on('itemRemoved', item => { board.removeElement(item.index.toString()) })
{“name”: “task text”}
, to which we’ll add a list
attribute indicating which of the three lists the task is located.addTask()
function we add the new list
attribute set to todo
, which is the first column, to all new tasks that are created: const addTask = async event => { event.preventDefault() newTaskField = event.target.elements.task const newTask = newTaskField.value console.log(newTask) newTaskField.value = '' const tasks = await syncClient.list('tasks') tasks.push({name: newTask, list: 'todo'}) }
const board = new jKanban({ ... dropEl: async (el, target, source) => { const sourceId = source.parentElement.dataset.id const targetId = target.parentElement.dataset.id if (sourceId === targetId) { return } const itemId = el.dataset.eid const name = el.innerText const tasks = await syncClient.list('tasks') tasks.set(itemId, {name, list: targetId}) } })
renderTask()
function, we use the list
attribute of the list item if available, defaulting to the first column if not: const renderTask = item => { board.addElement(item.data.list || "todo", { ... })
itemUpdated
event of the Sync list, so that we can update all connected applications when a task is moved between columns: tasks.on('itemUpdated', ({ item }) => { const id = item.index.toString() const element = board.findElement(id) board.removeElement(id) board.addElement(item.data.list, {id, 'title': element.innerHTML, class: "card", drag: el => el.classList.add('dragging'), dragend: el => el.classList.remove('dragging'), }) })
ngrok http 5001
on a separate terminal window to create a temporary public URL that tunnels requests into your locally running Flask server.fetch()
call in index.html.