[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!

Extracting Components

It’s finally time to clean up our code, and it’s really impressive how detail-oriented Damian gets when it comes to refactoring. One of the most common places where we can look for refactoring material are v-for loops, and there’s a bunch inside our Board.vue .

In the video we take the following steps to refactor the column out of the board:

  1. Create the /src/components/BoardColumn.vue file and move the template & CSS code for printing out each column into this file.
  2. Use this new component <BoardColumn /> inside the Board.vue
  3. Add the appropriate props, to send in column , and columnIndex .
  4. Look for the methods we need to move into the new component, and copy them over (without deleting them from the Board.vue .
  5. Search inside Board.vue to see which methods are no longer used and remove the ones that aren’t.
  6. Try things out in the browser, to make sure things work.
  7. Realize we need to pass in the board into the BoardColumn.vue as a prop.
  8. Try things out, and see it working!

Then we extract the tasks out of the column with the following steps:

  1. We create a /src/component/ColumnTask.vue file and move the template & CSS task code out of BoardColumn.vue .
  2. Send in task , taskIndex , and columnIndex as a prop to this new file.
  3. Import the ColumnTask , register it as a component, and use it inside of BoardColumn to loop through all tasks.
  4. Find the methods that need to be copied to ColumnTask .
  5. See if there are methods in BoardColumn we can remove.
  6. Remove the $ in front of the $taskIndex .
  7. Add column and board to the props, we forgot!
  8. Try it in the browser.

You can checkout our revised code here, but I’ve also listed it below:

:page_with_curl: /src/views/Board.vue

    <template>
      <div class="board">
        <div class="flex flex-row items-start">
          <BoardColumn
            v-for="(column, $columnIndex) of board.columns"
            :key="$columnIndex"
            :column="column"
            :columnIndex="$columnIndex"
            :board="board"
          />
          <div class="column flex">
            <input
              type="text"
              class="p-2 mr-2 flex-grow"
              placeholder="New Column Name"
              v-model="newColumnName"
              @keyup.enter="createColumn"
            >
          </div>
        </div>
        <div
          class="task-bg"
          v-if="isTaskOpen"
          @click.self="close"
        >
          <router-view/>
        </div>
      </div>
    </template>
    <script>
    import { mapState } from 'vuex'
    import BoardColumn from '@/components/BoardColumn'
    export default {
      components: { BoardColumn },
      data () {
        return {
          newColumnName: ''
        }
      },
      computed: {
        ...mapState(['board']),
        isTaskOpen () {
          return this.$route.name === 'task'
        }
      },
      methods: {
        close () {
          this.$router.push({ name: 'board' })
        },
        createColumn () {
          this.$store.commit('CREATE_COLUMN', {
            name: this.newColumnName
          })
          this.newColumnName = ''
        }
      }
    }
    </script>
    <style lang="css">
    .board {
      @apply p-4 bg-teal-dark h-full overflow-auto;
    }
    .task-bg {
      @apply pin absolute;
      background: rgba(0,0,0,0.5);
    }
    </style>

:page_with_curl: /src/components/BoardColumn.vue

    <template>
      <div
        class="column"
        draggable
        @drop="moveTaskOrColumn($event, column.tasks, columnIndex)"
        @dragover.prevent
        @dragenter.prevent
        @dragstart.self="pickupColumn($event, columnIndex)"
      >
        <div class="flex items-center mb-2 font-bold">
          {{ column.name }}
        </div>
        <div class="list-reset">
          <ColumnTask
            v-for="(task, $taskIndex) of column.tasks"
            :key="$taskIndex"
            :task="task"
            :taskIndex="$taskIndex"
            :column="column"
            :columnIndex="columnIndex"
            :board="board"
          />
          <input
            type="text"
            class="block p-2 w-full bg-transparent"
            placeholder="+ Enter new task"
            @keyup.enter="createTask($event, column.tasks)"
          />
        </div>
      </div>
    </template>
    <script>
    import ColumnTask from './ColumnTask'
    export default {
      components: { ColumnTask },
      props: {
        column: {
          type: Object,
          required: true
        },
        columnIndex: {
          type: Number,
          required: true
        },
        board: {
          type: Object,
          required: true
        }
      },
      methods: {
        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)
          }
        },
        moveTask (e, toTasks, 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,
            toTasks,
            toTaskIndex
          })
        },
        moveColumn (e, toColumnIndex) {
          const fromColumnIndex = e.dataTransfer.getData('from-column-index')
          this.$store.commit('MOVE_COLUMN', {
            fromColumnIndex,
            toColumnIndex
          })
        },
        pickupColumn (e, fromColumnIndex) {
          e.dataTransfer.effectAllowed = 'move'
          e.dataTransfer.dropEffect = 'move'
          e.dataTransfer.setData('from-column-index', fromColumnIndex)
          e.dataTransfer.setData('type', 'column')
        },
        createTask (e, tasks) {
          this.$store.commit('CREATE_TASK', {
            tasks,
            name: e.target.value
          })
          e.target.value = ''
        }
      }
    }
    </script>
    <style lang="css">
    .column {
      @apply bg-grey-light p-2 mr-4 text-left shadow rounded;
      min-width: 350px;
    }
    </style>

:page_with_curl: /src/components/ColumnTask.vue

    <template>
      <div
        class="task"
        draggable
        @dragstart="pickupTask($event, taskIndex, columnIndex)"
        @click="goToTask(task)"
        @dragover.prevent
        @dragenter.prevent
        @drop.stop="moveTaskOrColumn($event, column.tasks, columnIndex, 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>
    </template>
    <script>
    export default {
      props: {
        task: {
          type: Object,
          required: true
        },
        taskIndex: {
          type: Number,
          required: true
        },
        column: {
          type: Object,
          required: true
        },
        columnIndex: {
          type: Number,
          required: true
        },
        board: {
          type: Object,
          required: true
        }
      },
      methods: {
        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')
        },
        goToTask (task) {
          this.$router.push({ name: 'task', params: { id: task.id } })
        },
        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)
          }
        },
        moveTask (e, toTasks, 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,
            toTasks,
            toTaskIndex
          })
        },
        moveColumn (e, toColumnIndex) {
          const fromColumnIndex = e.dataTransfer.getData('from-column-index')
          this.$store.commit('MOVE_COLUMN', {
            fromColumnIndex,
            toColumnIndex
          })
        }
      }
    }
    </script>
    <style lang="css">
    .task {
      @apply flex items-center flex-wrap shadow mb-2 py-2 px-2 rounded bg-white text-grey-darkest no-underline;
    }
    </style>

You might notice that our BoardColumn and our ColumnTask components share duplicate data and methods, which we can eliminate by using a Mixin. We’ll see that in the next lesson.

Creating a Mixin

In the previous lesson we separated our code one step at a time into a BoardColumn and ColumnTask component. However, these components are sharing duplicate data and methods, which makes them prime candidates for creating a Mixin.

  1. Create a /mixins/movingTasksAndColumnsMixin.js file
  2. Move the common props of column , columnIndex , and board
  3. Move the methods moveTaskOrColumn , moveTask , and moveColumn .
  4. Import the Mixin into each of the files and configure it.

We also walk through the code base to see the work we’ve done, and discuss when it is appropriate to use a Mixin and when it might not.

Our mixin ends up looking like this:

/src/mixins/movingTasksAndColumnsMixin.js

    export default {
      props: {
        column: {
          type: Object,
          required: true
        },
        columnIndex: {
          type: Number,
          required: true
        },
        board: {
          type: Object,
          required: true
        }
      },
      methods: {
        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)
          }
        },
        moveTask (e, toTasks, 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,
            toTasks,
            toTaskIndex
          })
        },
        moveColumn (e, toColumnIndex) {
          const fromColumnIndex = e.dataTransfer.getData('from-column-index')
          this.$store.commit('MOVE_COLUMN', {
            fromColumnIndex,
            toColumnIndex
          })
        }
      }
    }

With these pieces of code moved into a Mixin we can now shorted our BoardColumn and ColumnTask components:

/src/components/BoardColumn.vue

    ...
    <script>
    import ColumnTask from './ColumnTask'
    import movingTasksAndColumnsMixin from '@/mixins/movingTasksAndColumnsMixin'
    
    export default {
      components: { ColumnTask },
      mixins: [movingTasksAndColumnsMixin],
      methods: {
        pickupColumn (e, fromColumnIndex) {
          ...
        },
        createTask (e, tasks) {
          ...
        }
      }
    }
    </script>

/src/components/ColumnTask.vue

    ...
    <script>
    import movingTasksAndColumnsMixin from '@/mixins/movingTasksAndColumnsMixin'
    
    export default {
      mixins: [movingTasksAndColumnsMixin],
      props: {
        task: {
          type: Object,
          required: true
        },
        taskIndex: {
          type: Number,
          required: true
        }
      },
      methods: {
        pickupTask (e, taskIndex, fromColumnIndex) {
          ...
        },
        goToTask (task) {
          this.$router.push({ name: 'task', params: { id: task.id } })
        }
      }
    }
    </script>

And that’s all there is to it. In the next and final lesson we will extract drag and drop functionality into reusable components.

Extracting Drag & Drop

Using the drag and drop API has made our code pretty complicated. In this lesson we turn this API into a reusable set of components that we can use whenever we need the ability to drag and drop. This will help us simplify our code.

We’ll start by creating basic components for our Drag and Drop functionality.

:page_with_curl: src/components/ App Drag.vue

    <template>
      <div
        draggable
        @dragstart.self="onDrag"
        @dragover.prevent
        @dragenter.prevent
      >
        <slot/>
      </div>
    </template>
    
    <script>
    export default {
      props: {
        transferData: {
          type: Object,
          required: true
        }
      },
      methods: {
        onDrag (e) {
          e.dataTransfer.effectAllowed = 'move'
          e.dataTransfer.dropEffect = 'move'
          e.dataTransfer.setData('payload', JSON.stringify(this.transferData))
        }
      }
    }
    </script>
    
    <style lang="css" scoped>
    </style>

:page_with_curl: src/components/ App Drag.vue

    <template>
      <div
        @drop.stop="onDrop"
        @dragover.prevent
        @dragenter.prevent
      >
        <slot/>
      </div>
    </template>
    
    <script>
    export default {
      methods: {
        onDrop (e) {
          const transferData = JSON.parse(e.dataTransfer.getData('payload'))
          this.$emit('drop', transferData)
        }
      }
    }
    </script>
    
    <style lang="css" scoped>
    </style>

Now that we should use these two components. First, let’s refactor the BoardColumn to use them.

:page_with_curl: src/components/ BoardColumn .vue

    <template>
      <AppDrop
        @drop="moveTaskOrColumn"
      >
        <AppDrag
          class="column"
          :transferData="{
            type: 'column',
            fromColumnIndex: columnIndex
          }"
        >
          <div class="flex items-center mb-2 font-bold">
            {{ column.name }}
          </div>
          <div class="list-reset">
            <ColumnTask
              v-for="(task, $taskIndex) of column.tasks"
              :key="$taskIndex"
              :task="task"
              :taskIndex="$taskIndex"
              :column="column"
              :columnIndex="columnIndex"
              :board="board"
            />
    
            <input
              type="text"
              class="block p-2 w-full bg-transparent"
              placeholder="+ Enter new task"
              @keyup.enter="createTask($event, column.tasks)"
            />
          </div>
        </AppDrag>
      </AppDrop>
    </template>
    
    <script>
    import ColumnTask from './ColumnTask'
    import AppDrag from './AppDrag'
    import AppDrop from './AppDrop'
    import movingTasksAndColumnsMixin from '@/mixins/movingTasksAndColumnsMixin'
    
    export default {
      components: {
        ColumnTask,
        AppDrag,
        AppDrop
      },
      mixins: [movingTasksAndColumnsMixin],
      ...

In order to make this work the moveTaskOrColumn function needs to be updated inside our mixin, since now only the transferData is being passed in as an argument.

:page_with_curl: src/ mixins / movingTasksAndColumnsMixin.js

    export default {
      ...
      methods: {
        moveTaskOrColumn (transferData) {
          if (transferData.type === 'task') {
            this.moveTask(transferData)
          } else {
            this.moveColumn(transferData)
          }
        },
        moveTask ({ fromColumnIndex, fromTaskIndex }) {
          const fromTasks = this.board.columns[fromColumnIndex].tasks
          this.$store.commit('MOVE_TASK', {
            fromTasks,
            fromTaskIndex,
            toTasks: this.column.tasks,
            toTaskIndex: this.taskIndex
          })
        },
        moveColumn ({ fromColumnIndex }) {
          this.$store.commit('MOVE_COLUMN', {
            fromColumnIndex,
            toColumnIndex: this.columnIndex
          })
        }
      }
    }

We then can simplify the ColumnTask.vue component to use our drag and drop component:

:page_with_curl: src/components/ ColumnTask .vue

    <template>
      <AppDrop
        @drop="moveTaskOrColumn"
      >
        <AppDrag
          class="task"
          :transferData="{
            type: 'task',
            fromColumnIndex: columnIndex,
            fromTaskIndex: taskIndex
          }"
          @click="goToTask(task)"
        >
          <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>
        </AppDrag>
      </AppDrop>
    </template>
    
    <script>
    import movingTasksAndColumnsMixin from '@/mixins/movingTasksAndColumnsMixin'
    import AppDrag from './AppDrag'
    import AppDrop from './AppDrop'
    
    export default {
      components: { AppDrag, AppDrop },
      mixins: [movingTasksAndColumnsMixin],
      props: {
        task: {
          type: Object,
          required: true
        },
        taskIndex: {
          type: Number,
          required: true
        }
      },
      methods: {
        goToTask (task) {
          this.$router.push({ name: 'task', params: { id: task.id } })
        }
      }
    }
    </script>

As you can see, we were able to remove the whole pickTask method since it has become redundant.

Now we have two components, AppDrag and AppDrop, we can easily use whenever we need to implement any sort of drag and drop functionality in the future.

You made it to the end, congratulations for that!

1 Like

How could I download the videos?

Thank you very much!