Vue.jsを利用し、スクラッチからTrello風タスク管理のアプリケーションを作成します。Trelloはタスク管理ツールで付箋のようなカードにタスクを記述して、カンバン方式で感覚的にタスクの管理を行うことができます。個人的なタスクの管理だけではなくチームのコラボレーションツールとしても利用することができるためこの記事を読んでいる人も利用している人はいるのではないでしょうか。

Trello風タスク管理のアプリケーションの作成を2回に分けて公開する予定ですが、今回はタスクとカテゴリー(カラム)のDrag&Drop機能の実装を行います。次回はタスク、カテゴリーの追加・更新機能の実装について説明を行います。

1回目である本記事を読み終えると下記のように動作するアプリケーションを作成することができます。Drag&Dropをどのように行うのか知りたい入門者の方におすすめの内容です。

作成するアプリケーション
作成するアプリケーション

先日公開した”スクラッチからVue.jsで作る自作ガントチャート ”と同じカテゴリー・タスクデータを利用して作成を行うのでガントチャートとの連携も可能です。

環境

特別な環境を構築することなく手元の環境ですぐに行えるようにVue.jsのバージョン3のcdnを利用して行っていきます。またCSSにはTailwindCssを利用しています。


<script src="https://unpkg.com/vue@next"></script>
<link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet">
Vue 3のComposiont APIは利用していません。本アプリケーションではVue 3のTeleport機能をタスクの更新のモーダルウィンドウで利用する予定です。

任意の場所にindex.htmlファイルを作成し下記のコードを記述します。


<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script src="https://unpkg.com/vue@next"></script>
    <link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet">
    <title>Trello風タスク管理</title>
</head>
<body>
    <div id="app">
    </div>
</body>
</html>
<script>
    const app = Vue.createApp({

    }).mount('#app')
</script>

利用するデータ

タスクはカテゴリーに所属し、カテゴリー毎にカラムを作成します。カテゴリーデータとタスクデータは下記を利用します。


data() {
  return {
    categories: [
      {
        id: 1,
        name: 'テストA',
        collapsed: false,
      }, 
      {
        id: 2,
        name: 'テストB',
        collapsed: false,
      },
        {
        id: 3,
        name: 'テストC',
        collapsed: false,
      },             
    ],
    tasks: [
      {
        id: 1,
        category_id: 1,
        name: 'テスト1',
        start_date: '2020-12-18',
        end_date: '2020-12-20',
        incharge_user: '鈴木',
        percentage: 100,
      },
      {
        id: 2,
        category_id: 1,
        name: 'テスト2',
        start_date: '2020-12-19',
        end_date: '2020-12-23',
        incharge_user: '佐藤',
        percentage: 90,
      },
      {
        id: 3,
        category_id: 3,
        name: 'テスト3',
        start_date: '2020-12-19',
        end_date: '2020-12-21',
        incharge_user: '鈴木',
        percentage: 40,
      },
      {
        id: 4,
        category_id: 2,
        name: 'テスト4',
        start_date: '2020-12-21',
        end_date: '2020-12-30',
        incharge_user: '山下',
        percentage: 60,
      },
      {
        id: 5,
        category_id: 2,
        name: 'テスト5',
        start_date: '2020-12-20',
        end_date: '2020-12-22',
        incharge_user: '佐藤',
        percentage: 5,
      },
      {
        id: 6,
        category_id: 1,
        name: 'テスト6',
        start_date: '2020-12-28',
        end_date: '2020-12-08',
        incharge_user: '佐藤',
        percentage: 0,
      },
    ],
  }
},

カテゴリーとタスクデータが別々の配列として保存されているので、カテゴリーデータの中にタスクデータをネスト化します。ネスト化にはcomputedプロパティdisplayCategoriesを利用しています。displayCategoriesはカテゴリーの中にタスクを含んでいるのでv-forで2回展開します。一度目はカテゴリーを展開し、二度目にタスクを展開しすべてのカテゴリーデータとタスクデータをブラウザに表示させます。


computed: {
  displayCategories() {
    let categories = [];
    let tasks = ""
    this.categories.map(category => {
      tasks = this.tasks.filter(task => task.category_id === category.id);
      categories.push({
        id: category.id,
        name: category.name,
        tasks
      })
    })
    return categories;
  }
},

最初から上記のようにcategoriesとtasksを分けてカテゴリー、タスクデータを準備するのではなく、categoriesの中にtasksをネスト化した下記の形のデータから開始しても問題ありません。computedプロパティdisplayCategoriesを実行することで下記のcategoriesと同じ構造のデータが作成されます。


categories: [
  {
    id: 1,
    name: 'テストA',
    collapsed: false,
    tasks:[
      {
        id: 1,
        category_id: 1,
        name: 'テスト1',
        start_date: '2020-12-18',
        end_date: '2020-12-20',
        incharge_user: '鈴木',
        percentage: 100,
      },
      {
        id: 2,
        category_id: 1,
        name: 'テスト2',
        start_date: '2020-12-19',
        end_date: '2020-12-23',
        incharge_user: '佐藤',
        percentage: 90,
      },
   //略
    ]
  }, 
最初からネスト化したデータを利用する場合はv-forで展開する際にcomputedプロパティのdisplayCategoriesを利用するのではなくデータプロパティのcategoriesを利用してください。

ブラウザへのデータの表示

カテゴリーデータの表示

computedプロパティのdisplayCategoriesデータを展開し、カテゴリー名をブラウザ上に表示させます。flexboxを利用して展開することでカテゴリーを横並にします。


<div id="app">
  <div id="trello-header" class="h-12 p-2">
    <h1 class="text-sm font-bold">Trello風タスク管理</h1>
  </div>
  <div id="trello-content" class="flex">
    <div
      v-for="(category,index) in displayCategories" 
      :key="index"
      >
      {{ category.name }}
    </div>
  </div>
</div>
カテゴリーの表示
カテゴリーの表示

カテゴリー毎にカラムにするため、min-widthで幅を設定します。


<div
  v-for="(category,index) in displayCategories" 
  :key="index"
  style="min-width:400px"
  >
  {{ category.name }}
</div>

背景に色がついていないのでわかりにくいですが、カテゴリー毎に400pxの幅を確保し3つのカラムとなります。ブラウザのウィンドウが3つのカラムの幅よりも狭い場合はブラウザの下部にスクロールが表示されます。

各カテゴリーが400pxの幅を持つ領域を確保
各カテゴリーが400pxの幅を持つ領域を確保
min-widthではなくwidthで幅を設定した場合は、flexboxによりブラウザの幅に3つの領域が入るように調整され、スクロールバーは表示されません。

カテゴリーにグレーの背景色をつけ、各カテゴリーをすぐに区別できるようにします。


<div id="trello-content" class="flex">
  <div
    v-for="(category,index) in displayCategories" 
    :key="index"
    style="min-width:400px"
    >
    <div class="bg-gray-200 m-2 p-2 text-sm">
    {{ category.name }}
    </div>
  </div>
</div>

ブラウザで確認すると下記のようにカテゴリーの背景に色がつき、カテゴリー間に空白が入るためカテゴリーを容易に区別することができます。

カテゴリーに背景をつける
カテゴリーに背景をつける

タスクデータを表示する

カテゴリーのデータを展開した後は、カテゴリーの領域の中にタスクを表示させます。categoryの中に入っているtasksプロパティを展開します。


<div
  v-for="(category,index) in displayCategories" 
  :key="index"
  style="min-width:400px"
  >
  <div class="bg-gray-200 m-2 p-2 text-sm">
    <div class="font-bold">{{ category.name }}</div>
    <div 
      v-for="(task,index) in category.tasks" 
      :key="index" 
      class="m-2 bg-white p-2">
      {{ task.name}}
    </div>
  </div>
</div>

ブラウザで確認するとカテゴリー領域の中にタスクが表示されることが確認できます。

タスクを表示
タスクを表示

Drag&Drop(ドラッグ&ドロップ)の設定

今回のアプリケーションでは3つのパターンのタスクの移動があります。

※3つ目の移動はタスクの移動ではなくタスクを含むカテゴリーの移動です。

  • 同じカテゴリー内でのタスク移動
  • 別のカテゴリーへのタスク移動
  • カテゴリーの移動

同じカテゴリー内でのタスク移動

最初に同じカテゴリー内(カラム)でのタスクの移動を設定していきます。

タスクを移動させるためにはタスクの要素に対してdraggableの設定を行う必要があります。

draggable属性をtrueに設定すると要素をDrag&Dropすることが可能になります。draggaleを設定すると以下のようにタスク要素のテスト2を掴んで動かせるようになります。


<div 
  v-for="(task,index) in category.tasks" 
  :key="index" 
  class="m-2 bg-white p-2"
  draggable=true
>
  {{ task.name}}
</div>
draggableを設定するとは
draggableを設定するとは

要素をdraggableすると要素を掴んでいる間はブラウザ上を移動することができますが、マウスのクリックを外すとDragした要素は元の場所に戻ります。

次にdragstartイベントを設定します。dragstartイベントを設定すると要素をDrag(マウスでクリックして掴む)した直後にイベントが発生し指定した処理を行うことができます。dragstartイベントにdragTaskメソッドを設定し動作確認を行います。


<div 
  v-for="(task,index) in category.tasks" 
  :key="index" 
  class="m-2 bg-white p-2"
  @dragstart="dragTask"
  draggable=true
>
  {{ task.name}}
</div>

Vue.jsのmethodsにdragTaskメソッドを追加します。


methods:{
  dragTask(){
    console.log('drag')
  }
},

設定後タスクの要素をDragするとデベロッパーツールのコンソールにdragが表示されます。表示されたらdragstartイベントの動作確認は完了です。

今回のようにdrag&dropを行いタスクを移動する場合、Dragするタスクの要素を一度tasks配列の元存在してい場所から削除し、Dropされた場所で新たに追加することでタスクの移動を実現します。Drag中にはDragしている要素の情報が必要となるため、Vue.jsにデータプロパティtaskを追加し、その中にdragしているtaskの情報を保存します。


data() {
    return {
      task:'',
//略

データプロパティを追加したら、dragStartメソッドの中でDragしたtask情報を設定します。そのためHtml側のdragTaskメソッドの引数にtaskを設定します。


@dragstart="dragTask(task)"

dragStartメソッドで引数taskを受け取り、データプロパティtaskに設定します。


methods:{
  dragTask(task){
    this.task = task;
  }
},

次にdragoverイベントを利用して、dragしている要素の下にある要素のタスク情報を取得します。dragしている要素とその下の要素の関係は以下の通りです。

dragしている要素とその下の要素
dragしている要素とその下の要素

dragoverイベントにはdragOverTaskメソッドを設定し、引数にはtaskを指定します。


<div 
  v-for="(task,index) in category.tasks" 
  :key="index" 
  class="m-2 bg-white p-2"
  @dragstart="dragTask(task)"
  @dragover="dragOverTask(task)"
  draggable=true
>
  {{ task.name}}
</div>

タスク入れ替え処理

dragOverTask内ではdrag要素をしているタスクとdrag要素の下にいる要素のタスクを入れ替えを行います。

入れ替え処理を実行するのはdragしている要素が別の要素の上にいる場合のみです。Drag要素が自要素の上にいる時は何も処理を行いません。taskのidを利用してtasksの中から配列の番号を取得し一度削除を行いdragoverしている要素がいる配列の場所に挿入を行っています。


dragOverTask(overTask){
  if (overTask.id !== this.task.id) {
    let deleteIndex;//削除を行うIndex保存
    let addIndex;//追加を行うIndex保存
    this.tasks.map((task, index) => { if (task.id === this.task.id) deleteIndex = index })
    this.tasks.map((task, index) => { if (task.id === overTask.id) addIndex = index })
    this.tasks.splice(deleteIndex, 1)
    this.tasks.splice(addIndex, 0, this.task)
  }    
}

上記の設定で同じカテゴリー内であればタスクの移動が可能です。

テスト6のタスクをDrag&Dropにより一番上に移動することができます。

タスク6を移動する前
タスク6を移動する前

移動後はタスクAの一番上にタスク6が表示されます。

タスク6が移動した後
タスク6が移動した後

ここまでの設定で同じカテゴリー内でのタスク移動の処理は完了です。タスクの移動が行われた後にdragした要素が元の場所にもどる場合はdragoverにpreventを設定してください。


@dragover.prevent="dragOverTask(task)"

別のカテゴリーへのタスクの移動

次に別のカテゴリーへのタスクの移動を実装していきましょう。現在の設定では別のカテゴリーへDrag&Dropを行ってもブラウザ上に何も変化はありません。

タスクには所属するカテゴリーのIDを持っているので、別のカテゴリーに移動した場合は移動したタスクのcategory_idに更新する必要があります。category_idの更新の処理をdragOverTaskメソッドの中に追加します。

overTask(移動先の要素)のcategory_idをdragしたタスクのcategory_idに設定します。


dragOverTask(overTask){
  if (overTask.id !== this.task.id) {
    let deleteIndex;
    let addIndex;
    this.tasks.map((task, index) => { if (task.id === this.task.id) deleteIndex = index })
    this.tasks.map((task, index) => { if (task.id === overTask.id) addIndex = index })
    this.tasks.splice(deleteIndex, 1)
    this.task.category_id = overTask.category_id //追加
    this.tasks.splice(addIndex, 0, this.task)
  }    
}

設定後、テストAのテスト6を別のカテゴリーであるテストBに移動します。

タスク6を移動する前
タスク6を移動する前

テストBへ移動できることが確認できます。

別のカテゴリーへのタスクの移動
別のカテゴリーへのタスクの移動

タスクのないカテゴリーへの移動

カテゴリー間のタスク移動が確認できましたが、下記のようにカテゴリーテストCにタスクがない場合はDrag&Dropでのタスクの移動は行えません。

タスクのないカテゴリーへの移動
タスクのないカテゴリーへの移動

移動ができない理由はdragoverイベントがカテゴリー要素に設定されていないためです。dragoverイベントが設定されているのは現時点ではタスク要素のみです。

カテゴリー要素にもdragoverイベントを追加し、dragOverCategoryメソッドを設定します。


<div class="bg-gray-200 m-4 p-2 text-sm"
  @dragover="dragOverCategory(category)"
  >
  <div class="font-bold">{{ category.name }}</div>
  <div 
    v-for="(task,index) in category.tasks" 
    :key="index" 
    class="m-2 bg-white p-2"
    @dragstart="dragTask(task)"
    @dragover="dragOverTask(task)"
    draggable=true
  >
    {{ task.name}}
  </div>
</div>

Vue.jsのmethodsにdragOverCategoryメソッドを追加します。dragOverCategoryメソッドの中ではまず移動するタスクのcategory_idとdrag要素の下にあるカテゴリーのcategory_idが異なる場合のみ処理が行われます。

カテゴリーの中にタスクが入っていない場合のみカテゴリーのcategory_idをタスクのcategory_idに設定しています。カテゴリーにタスクがある場合はdragOverTaskメソッドの処理に任せています。(別のカテゴリーへのタスクの移動)


dragOverCategory(overCategory){
  if (this.task.category_id !== overCategory.id) {
    let tasks = this.tasks.filter(task => task.category_id === overCategory.id)
    if (tasks.length === 0) this.task.category_id = overCategory.id;
  }         
}

カテゴリー要素へのdragoverイベント追加後は、タスクがないカテゴリーにも移動が可能となります。テストCにテストBのテスト4をDrag&Dropします。

タスクのないカテゴリーへの移動
タスクのないカテゴリーへの移動前
タスクのないカテゴリーへのタスク移動
タスクのないカテゴリーへのタスク移動

タスクの移動が行われた後にdragした要素が元の場所にもどる場合はdragoverにpreventを設定してください。


@dragover.prevent="dragOverCategory(category)"

カテゴリーの移動

タスクは同一のカテゴリーでも別のカテゴリー、またタスクが存在しないカテゴリーにも移動できるようになりました。次はカテゴリーの移動を行います。

カテゴリー要素をDragできるようにdraggable属性を設定します。


<div 
  class="bg-gray-200 m-4 p-2 text-sm"
  @dragover.prevent="dragOverCategory(category)"
  draggable=true
  >

draggableを設定すると下記のようにカテゴリー全体をDragすることができるようになります。

カテゴリーをDrag
カテゴリーをDrag

Dragしても現在の設定ではカテゴリーを正常に移動することはできません。

タスクと同様にカテゴリー情報を保存するデータプロパティcategoryを追加します。


data() {
    return {
//略
      category:'',

カテゴリー要素にdragstartイベントを追加し、dragCategoryメソッドを設定します。


<div class="bg-gray-200 m-4 p-2 text-sm"
  @dragstart.self="dragCategory(category)"
  @dragover="dragOverCategory(category)"
  draggable=true
  >
  <div class="font-bold">{{ category.name }}</div>
  <div 
    v-for="(task,index) in category.tasks" 
    :key="index" 
    class="m-2 bg-white p-2"
    @dragstart="dragTask(task)"
    @dragover="dragOverTask(task)"
    draggable=true
  >
    {{ task.name}}
  </div>
</div>

これまではdragStartでDragされる要素はタスクのみでしたがカテゴリーもDragすることができることになったのでどちらの要素がDragされどちらの要素がDragOverしていているのかを識別する必要が出てきます。識別を行うために新たにデータプロパティtypeを追加し、dragstartイベントのメソッドであるdragTaskとdragCategoryメソッドの中でtypeに値を入れます。dragCategoryメソッドを新たに追加してください。


data() {
    return {
      task:'',
      category:'',
      type:'',
      categories:...,
      tasks:...,

dragTask(task){
  this.task = task;
  this.type = "task";
},
dragCategory(category){
  this.category = category;
  this.type = "category";
},

タスク要素の上にカテゴリーがきても処理を行わないようにdragOverTaskを以下のように更新します。if文の条件にtypeがtaskであることを追加。カテゴリー要素がDragされてタスク要素の上にきてもタスク要素では何も処理はしません。


dragOverTask(overTask){
  if (overTask.id !== this.task.id && this.type === 'task') {
    let deleteIndex;
    let addIndex;
    this.tasks.map((task, index) =>> { if (task.id === this.task.id) deleteIndex = index })
    this.tasks.map((task, index) => { if (task.id === overTask.id) addIndex = index })
    this.tasks.splice(deleteIndex, 1)
    this.task.category_id = overTask.category_id
    this.tasks.splice(addIndex, 0, this.task)
  }    
},

dragOverCategoryではカテゴリー要素が上にきた場合とタスク要素がきた場合で異なる処理を実行します。カテゴリーがきた場合は入れ替えを行います。categoryのidを利用してcategoriesの中から配列の番号を取得し一度削除を行いdragoverしている要素がいる配列の場所に挿入を行っています。

タスクの場合は先ほど設定した通り、カテゴリーに要素がない場合のみタスクの移動処理をdragOverCategoryで行います。要素がある場合はdragOverTaskで処理を行います。


dragOverCategory(overCategory) {
  if (overCategory.id !== this.category.id && this.type === "category") {
    let deleteIndex;
    let addIndex;
    this.categories.map((category, index) => { if (category.id === this.category.id) deleteIndex = index })
    this.categories.map((category, index) => { if (category.id === overCategory.id) addIndex = index })
    this.categories.splice(deleteIndex, 1)
    this.categories.splice(addIndex, 0, this.category)
  } else {
    if (this.task.category_id !== overCategory.id && this.type === "task") {
      let tasks = this.tasks.filter(task => task.category_id === overCategory.id)
      if (tasks.length === 0) this.task.category_id = overCategory.id;
    }
  }
}

ここまでの設定で、タスクとカテゴリーの移動を実装することができました。

作成するアプリケーション
作成するアプリケーション

次回はタスク・カテゴリーの追加・更新方法を実装していきます。