[VueMastery] Watch Us Build a Trello Clone

lesson1

Tour of the App

In this first Watch Us Build lesson Gregg Pollack works alongside Damian Dulisz, a Vue Core team member, to build a Trello like clone. Damian is also known for his work on the Vue-multiselect library and Vuelidate libraries.

In this course, we will be building an application together step by step including:

  • Setting up Vuex to read & write.
  • Saving state to local storage
  • Using the browser drag and drop API
  • Refactor big components into smaller ones
  • Creating reusable components
  • Additional refactoring

We welcome you to code along with us, which you can do by cloning this repo and checking out the ‘application-start’ tag with git checkout application-start , or just downloading this startup code. We will provide you with all the code we are writing below each video, and if you get stuck the finishing code for each level is tagged appropriately.

I think you’ll find Damian’s approach to refactoring components extremely useful and implementing the browser’s drag and drop API is fun to watch. In the next lesson we will get familiar with the code base, and start hooking up our data out of Vuex.

Building our Board

In this lesson we start by taking a tour of our starting code. Please feel free to download and follow along if you like. We’ll be building out our initial Trello Board columns and tasks by pulling it out of Vuex. We’ll also learn how to save and load our Board’s state from our browser’s localstorage.

Feel free to copy paste the code below if you get stuck, look at the differences in GitHub, or simply clone the repo and check out the lesson-2-complete tag.

:scroll: /src/views/Board.vue

<template>
  <div class="board">
    <div class="flex flex-row items-start">
      <div
        class="column"
        v-for="(column, $columnIndex) of board.columns"
        :key="$columnIndex"
      >
        <div class="flex items-center mb-2 font-bold">
          {{ column.name }}
        </div>
        <div class="list-reset">
          <div
            class="task"
            v-for="(task, $taskIndex) of column.tasks"
            :key="$taskIndex"
          >
            <span class="w-full flex-no-shrink font-bold">
              {{ task.name }}
            </span>
            <p
              v-if="task.description"
              class="w-full flex-no-shrink mt-1 text-sm"
            >
              {{ task.description }}
            </p>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { mapState } from 'vuex'

export default {
  computed: mapState(['board'])
}
</script>

We’ll also create a small saveStatePlugin so we can listen for mutations being made to our Vuex store, and store them in the local browser storage. This way if we refresh our browser, our board won’t get reset.

:scroll: /src/utils.js

...
export function saveStatePlugin (store) {
  store.subscribe(
    (mutation, state) => {
      localStorage.setItem(
        'board',
        JSON.stringify(state.board)
      )
    }
  )
}

We’ll then need to use this plugin and restore the board from local storage if it already exists.

:scroll: /src/store.js

import Vue from 'vue'
import Vuex from 'vuex'
import defaultBoard from './default-board'
import { saveStatePlugin } from './utils' // <-- Import saveStatePlugin

Vue.use(Vuex)

const board = JSON.parse(localStorage.getItem('board')) || defaultBoard

export default new Vuex.Store({
  plugins: [saveStatePlugin], // <-- Use 
  state: {
    board
  },

In the next lesson we’ll open up our tasks in a modal. See you then!

Opening Tasks in a Modal

In this lesson we implement a modal showing task information using a child route. This opens when we click on a task in our board and closes when we click on the background. I hadn’t learned how to create a modal which is also a child route, and this is a great example.

We start by adding the child route.

:scroll: /src/router.js

...
import Task from './views/Task.vue' // <-- Add Task

Vue.use(Router)

export default new Router({
  ...
  routes: [
    {
      path: '/',
      name: 'board',
      component: Board,
      children: [ // <-- Add child component
        {
          path: 'task/:id',
          name: 'task',
          component: Task
        }
      ]
    }
  ]
})

We also add a getter to our Vuex store so we can search for the task based on the ID from the url.

:scroll: /src/store.js

  ...
  state: {
    board
  },
  getters: { // <-- Add a getter
    getTask (state) {
      return (id) => {
        for (const column of state.board.columns) {
          for (const task of column.tasks) {
            if (task.id === id) {
              return task
            }
          }
        }
      }
    }
  },
  mutations: {}
})

Our board contains the code to open and close the task modal.

:scroll: /src/views/Board.vue

...
          <div
            class="task"
            v-for="(task, $taskIndex) of column.tasks"
            :key="$taskIndex"
            @click="goToTask(task)"
          >
...
    <div
      class="task-bg"
      v-if="isTaskOpen"
      @click.self="close"
    >
      <router-view/>
    </div>
  </div>
</template>

<script>
import { mapState } from 'vuex'

export default {
  computed: {
    ...mapState(['board']),
    isTaskOpen () {
      return this.$route.name === 'task'
    }
  },
  methods: {
    goToTask (task) {
      this.$router.push({ name: 'task', params: { id: task.id } })
    },
    close () {
      this.$router.push({ name: 'board' })
    }
  }
}
</script>
...

And finally our Task.vue component fetches the appropriate task and displays it’s name in a modal.

:scroll:**/src/views/Task.vue**

<template>
  <div class="task-view">
    <div class="flex flex-col flex-grow items-start justify-between px-4">
      {{ task.name }}
    </div>
  </div>
</template>

<script>
import { mapGetters } from 'vuex'

export default {
  computed: {
    ...mapGetters(['getTask']),
    task () {
      return this.getTask(this.$route.params.id)
    }
  }
}
</script>
...

In the next lesson we’ll add the ability to add and edit tasks using forms, stay tuned!

Adding and Editing Tasks

Now that we can display tasks in a modal, let’s start working on actually being able to change the underlaying task data. We’ll start by adding a textarea to actually print out the task description:

:page_with_curl: /src/views/Task.vue

    <template>
       <div class="task-view">
         ...
         <textarea
           placeholder="Enter task description"
           class="relative bg-transparent px-2 border mt-2 h-64 border-none leading-normal"
           :value="task.description"
         />
         </div>
       </div>
     </template>

This will show our description on the modal we created for tasks from the last lesson.

To get started with the ability to add tasks we’ll create a Vuex mutation that can do just that. It will receive the lists of tasks within a column, the task name/title and simply push a new task into the list of tasks. We will also use uuid that we import from our utils, to create unique IDs.

:page_with_curl: /src/store.js

    import { uuid, saveStatePlugin } from './utils' // <--- uuid is being imported
    ...
    mutations: {
      CREATE_TASK (state, { tasks, name }) {
        tasks.push({
          name,
          id: uuid(),
          description: ''
        })
      }
    }
    ...

Now lets add the mentioned input near the end of the column code, just after the v-for with the tasks list, but within the v-for that iterates over the columns.

:page_with_curl: /src/views/Board.vue

    <template>
      ...
            <div class="list-reset">
              <div
                class="task"
                v-for="(task, $taskIndex) of column.tasks"
                :key="$taskIndex"
                @click="goToTask(task)"
              >
                ...
              </div>
    
              <input
                type="text"
                class="block p-2 w-full bg-transparent"
                placeholder="+ Enter new task"
                @keyup.enter="createTask($event, column.tasks)"
              />
            </div>
      ...

Let’s make use of that mutation inside Board.vue and add a createTask method that we will later use on an <input> element.

:page_with_curl: /src/views/Board.vue

    methods: {
      createTask (e, tasks) {
        this.$store.commit('CREATE_TASK', { tasks, name: e.target.value })
          // clear the input
        e.target.value = ''
      }
    }

Done! We can now start typing new tasks in the input inside each column and after pressing the enter key, it will be added at the end of the column.

While we’re at managing the tasks, let’s also add a way to edit them. For that we need one simple mutation in our Vuex store. I will name it UPDATE_TASK . The mutation is pretty straightforward. It accepts the task we want to modify, the property that we want to change on the task and the new value of that property.

:page_with_curl: /src/store.js

    CREATE_TASK (state, { tasks, name }) {
      tasks.push({
        name,
        id: uuid(),
        description: ''
      })
    },
    UPDATE_TASK (state, { task, key, value }) {
      Vue.set(task, key, value)
    }

The editing itself will, however, take place inside the detailed view, that is, inside the / src/views/Task.vue component. We will add two inputs there instead of just displaying the {{ task.name }} . This is how the component’s template should look after the changes.

:page_with_curl: /src/views/Task.vue

    <template>
       <div class="task-view">
         <div class="flex flex-col flex-grow items-start justify-between px-4">
         <input
           class="p-2 w-full mr-2 block text-xl font-bold"
           :value="task.name"
           @keyup.enter="updateTaskProperty($event, 'name')"
           @change="updateTaskProperty($event, 'name')"
         />
         <textarea
           placeholder="Enter task description"
           class="relative w-full bg-transparent px-2 border mt-2 h-64 border-none leading-normal"
           :value="task.description"
           @change="updateTaskProperty($event, 'description')"
         />
         </div>
       </div>
     </template>

As you probably noticed, we used a method called updateTaskProperty that is not yet present in our code, so let’s add it now to our Task.vue component. It will be the bridge between the interface and the Vuex mutation.

:page_with_curl: /src/views/Task.vue

    methods: {
       updateTaskProperty (e, key) {
         this.$store.commit('UPDATE_TASK', {
           task: this.task,
           key,
           value: e.target.value
         })
       }
    }

The method accepts the event and the property name that is being modified. It then forwards that information to the UPDATE_TASK mutation attaching the task in question as the 3rd param.

Now we can both add and update the tasks. There is just one more thing that we need to make the board fully functional as a task manager that will make it possible to track our progress on the tasks. And to accomplish this we need to be able to move the tasks between columns.

Drag & Dropping Tasks

In this lesson we will be using the browser’s drag and drop API to implement the ability to move around tasks in our Trello app. This isn’t the easiest to implement, so this example of how to do it with Vue should be very useful.

We’ll start with our Vuex store. To move the tasks around we will create a new mutation called MOVE_TASK . It’s purpose is to remove the task from the column it is located and move it to a new column. To make it happen, we need 3 arguments: the task list where the task is currently, the task list we want to move it to, and the index of where the task is located in the first column.

:page_with_curl: /src/store.js

...
  mutations: {
    ...
    MOVE_TASK (state, { fromTasks, toTasks, taskIndex }) {
      const taskToMove = fromTasks.splice(taskIndex, 1)[0]
      toTasks.push(taskToMove)
    }
  }

If the code above looks different than in the video, it’ll be changed to what I have listed above, as we thought of better parameter names. :wink: Now the hard part! We will need to dive a bit into the Drag’n’Drop browser API. It’s not the easiest browser API out there, so if you haven’t had the chance to use it yet, feel free to pause the video and take a look at the MDN docs. Although we will start with the most naive approach – by the end of this course, we will create a pretty nice abstraction on top of the API.

Let’s open the src/views/Board.vue component and start building. Because we want to move our tasks, we need to add the draggable attribute to our <div> that wraps our task. This will make it possible to /drag/ the div around along with its content. And only that for now at least.

Next step is that we need to react to the task being dragged. We can listen to @dragstart on the DOM element. The event listener will then be called with the default dragstart , injected via $event , the index of our task and the index of our column, $taskIndex and $columnIndex respectively. We will create the pickupTask method and use it here.

:page_with_curl: /src/views/Board.vue

    <div
      v-for="(task, $taskIndex) of column.tasks"
      :key="$taskIndex"
      class="task"
      draggable
      @dragstart="pickupTask($event, $taskIndex, $columnIndex)"
      @click="goToTask(task)"
    >

Let’s define this pickupTask method, which is a method in this component:

    pickupTask (e, taskIndex, fromColumnIndex) {
      e.dataTransfer.effectAllowed = 'move'
      e.dataTransfer.dropEffect = 'move'
      e.dataTransfer.setData('task-index', taskIndex)
      e.dataTransfer.setData('from-column-index', fromColumnIndex)
    },

Inside pickupTask we’re calling the setData method on the dataTransfer interface and saving the taskIndex and then the same for the sourceListIndex which stands for the index of the column where the task is located. Take note that the dataTransfer interface works pretty similar to localStorage in that it can only store properties that can be /stringified/. This means transferring recursive data structures or functions won’t work.

Now we need to make our column can accept an element being dropped. To make it happen we need another set of listeners, most importantly the @drop event.

    <div class="column"
      v-for="(column, $columnIndex) of board.columns"
      :key="$columnIndex"
      @drop="moveTask($event, column.tasks)"
      @dragover.prevent
      @dragenter.prevent
    >

As you can see, we also need to add two additional listeners, for the dragover and dragenter events and we need to prevent the default behavior. Pretty great that we’re using Vue, because we can simply add @dragover.prevent . Let’s focus on the @drop event. As the name suggests, it is being triggered when an element being dragged is released on top of our element. The listener will be called with the default $event and in our case also with the list of tasks in the column.

    moveTask (e, toTasks) {
      const fromColumnIndex = e.dataTransfer.getData('from-column-index')
      const fromTasks = this.board.columns[fromColumnIndex].tasks
      const taskIndex = e.dataTransfer.getData('task-index')
    
      this.$store.commit('MOVE_TASK', {
        fromTasks,
        toTasks,
        taskIndex
      })
    }

Now the moveTask method. Once it is called, we can take the information transferred through the dataTransfer interface and locate our tasks list. Now we have all the data we need to be able to call the MOVE_TASK mutation. And that’s it, moving tasks should be working now.

In the next lesson we’ll expand this functionality to allow for moving columns around, as well as the ability to move tasks within columns. Stay tuned for that.

Dragging Columns

In this lesson we’ll start with the Vuex mutation for moving columns.

:page_with_curl: /src/store.js

    MOVE_COLUMN (state, { fromColumnIndex, toColumnIndex }) {
      const columnList = state.board.columns
      const columnToMove = columnList.splice(fromColumnIndex, 1)[0]
      columnList.splice(toColumnIndex, 0, columnToMove)
    }

Similar to MOVE_TASK we accept the fro``mColumnIndex which is the index of the column we want to move, and the to``ColumnIndex which is the index where we want to move our column. Then we’ll add to our code in our Board component so that our columns become draggable.

:page_with_curl: /src/views/Board.vue

    <div class="column"
      v-for="(column, $columnIndex) of board.columns"
      :key="$columnIndex"
      draggable
      @drop="moveTaskOrColumn($event, column.tasks, $columnIndex)"
      @dragover.prevent
      @dragenter.prevent
      @dragstart.self="pickupColumn($event, $columnIndex)"
    >

Again, similar to what we did with the tasks, we need to make all columns draggable . Since now the we will be dropping both tasks and columns on other columns, we need to replace our @drop="moveTask" handler with a new one, that can decide which action to take. We’ll name it moveTaskOrColumn . Since our column is now draggable, we need to add a dragstart handler named pickupColumn .

We’ve also made a bunch of tweaks to the code in here, to add functionality for dragging and dropping in different places.

:page_with_curl: /src/views/Board.vue

    pickupTask (e, taskIndex, fromColumnIndex) {
      e.dataTransfer.effectAllowed = 'move'
      e.dataTransfer.dropEffect = 'move'
    
      e.dataTransfer.setData('from-task-index', taskIndex)
      e.dataTransfer.setData('from-column-index', fromColumnIndex)
      e.dataTransfer.setData('type', 'task') // <--- New code to identify task
    },
    pickupColumn (e, columnIndex) {
      e.dataTransfer.effectAllowed = 'move'
      e.dataTransfer.dropEffect = 'move'
    
      e.dataTransfer.setData('from-column-index', fromColumnIndex)
      e.dataTransfer.setData('type', 'column')
    },
    moveTaskOrColumn (e, toTasks, toColumnIndex, toTaskIndex) {
      const type = e.dataTransfer.getData('type')
      if (type === 'task') {
        this.moveTask(e, toTasks, toTaskIndex !== undefined ? toTaskIndex : toTasks.length)
      } else {
        this.moveColumn(e, toColumnIndex)
      }
    },
    moveColumn (e, toColumnIndex) {
      const fromColumnIndex = e.dataTransfer.getData('from-column-index')
      this.$store.commit('MOVE_COLUMN', {
        fromColumnIndex,
        toColumnIndex
      })
    } 

Okay, we can now move both columns and tasks between different columns, but tasks always end up at the very end of the list. That’s hardly useful. So let’s add a way to move the tasks into specific positions within a column. We need to update our template.

:page_with_curl: /src/views/Board.vue

    <div
      class="task"
      v-for="(task, $taskIndex) of column.tasks"
      :key="$taskIndex"
      draggable
      @dragstart="pickupTask($event, $taskIndex, $columnIndex)"
      @click="goToTask(task)"
      @dragover.prevent
      @dragenter.prevent
      @drop.stop="moveTaskOrColumn($event, column.tasks, $columnIndex, $taskIndex)"
     >

Similar to the column template, we need to add a drop event listener on every task, so that we can catch other tasks being dropped on top of it.

Now we need to modify moveTask so that it keeps track of where we drop our task, the task indexes.

    moveTask (e, toTasks, toTaskIndex) { // <--- Added toTaskIndex
      const fromColumnIndex = e.dataTransfer.getData('from-column-index')
      const fromTasks = this.board.columns[fromColumnIndex].tasks
      const fromTaskIndex = e.dataTransfer.getData('from-task-index')
      this.$store.commit('MOVE_TASK', {
        fromTasks,
        fromTaskIndex, // <-- added index
        toTasks,
        toTaskIndex // <-- added index
      })
    },

Then we’ll update our MOVE_TASK mutation to move to the proper index.

:page_with_curl: /src/store.js

    // now also accepts `targetIndex` 
    MOVE_TASK (state, { fromTasks, toTasks, fromTaskIndex, toTaskIndex }) {
      const taskToMove = fromTasks.splice(fromTaskIndex, 1)[0]
      toTasks.splice(toTaskIndex, 0, taskToMove)
    },

With these changes, we can now move tasks to a selected position within the tasks list, drag columns around, and drag tasks within columns.

Adding New Columns

There’s one thing we missed along the way – adding new columns. Let’s quickly fix that starting with the Vuex mutation.

:page_with_curl: /src/store.js

    CREATE_COLUMN (state, { name }) {
      state.board.columns.push({
        name,
        tasks: []
      })
    },

Now let’s add an “fake” column just after the v-for with the columns list. This is where we will have a small input to name our new column. This goes right after the div where we iterate through all the columns:

:page_with_curl: /src/views/Board.vue

    <div class="column flex">
      <input
        class="p-2 mr-2 flex-grow"
        placeholder="New column name"
        v-model="newColumnName"
        @keyup.enter="createColumn"
      />
    </div>

The template includes two new things: newColumnName from local state and createNewColumn method.

    export default {
      data () {
        return {
          newColumnName: ''
        }
      },
      // ...
      methods: {
        createColumn () {
          this.$store.commit('CREATE_COLUMN', {
            name: this.newColumnName,
          })
          this.newColumnName = ''
        },
        // ...
      }
    }

And now we can create new columns in our app!

How could I download the videos?