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

Trello風タスク管理のアプリケーションの作成を2回に分けて公開する予定ですが、今回はタスクとカテゴリー(カラム)の移動機能の実装を行います。移動にはdraggable属性とdragイベントを利用します。後半にはTypeScript+Composition APIを利用した場合の記述方法についても説明しています。propsやemitなど複数のコンポーネントで構成されたアプリケーションでのTypeScriptの設定方法を理解することができます。TypeScriptでのコードを確認したい場合は”TypeScriptを利用した場合”から読み進めてください。実装する内容はTypeScriptを利用する場合としない場合は同じです。

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

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

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

環境

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


<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は後半のTypeScriptと一緒に利用しています。本アプリケーションではVue3のTeleport機能をタスクの更新のモーダルウィンドウで利用する予定です。
fukidashi

任意の場所に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プロパティの利用

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


computed: {
  displayCategories() {
    return this.categories.map((category) => {
      const tasks = this.tasks.filter(
        (task) => task.category_id === category.id
      );
      return {
        id: category.id,
        name: category.name,
        tasks,
      };
    });
  },
},

最初から上記のように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を利用してください。
fukidashi

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

カテゴリーデータの表示

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つの領域が入るように調整され、スクロールバーは表示されません。
fukidashi

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


<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による移動処理の設定

今回作成するアプリケーションでは3つのパターンのタスクの移動があります。設定方法が異なるので1つ1つ設定方法を確認していきます。

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

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

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

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

draggableの設定

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

draggable属性をtrueに設定すると要素をDragで移動することが可能になります。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属性を設定するとdraggableを設定した要素の中で左クリックをするとマウスをクリックしている間はDragすることができブラウザ上を移動することができます。しかしマウスのクリックを外すDragした要素は元の場所に戻ります。

draggableで要素をドラッグすることがわかったので次はdragstartイベントを設定します。dragstartイベントを設定すると要素をDrag(マウスでクリックして掴む)した直後にイベントが発生し指定した処理を行うことができます。dragstartイベントにdragTaskメソッドを設定し動作確認を行います。イベントの設定にはv-onディレクティブを利用することができここでは略称表記の@を利用しています。


<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”の文字列が表示されます。どの要素をDragしてもDragを開始した直後にのみ”drag”の文字列が表示れます。

今回のようにdraggableの設定を使ってタスクを移動する場合、Dragするタスクの要素を一度tasks配列の元存在してい場所から削除し移動先の要素が存在する場所に削除した要素を追加することでタスクの移動を実現します。一度削除したタスク要素を追加するためにタスクの情報を保存しておく必要がありますデータプロパティtaskを追加しその中にdragしているタスク情報を保存します。


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) {
    const deleteIndex = this.tasks.findIndex(
      (task) => task.id === this.task.id
    );
    const addIndex = this.tasks.findIndex(
      (task) => task.id === overTask.id
    );
    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を行ってもブラウザ上に何も変化はありません。

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

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


dragOverTask(overTask) {
  if (overTask.id !== this.task.id) {
    const deleteIndex = this.tasks.findIndex(
      (task) => task.id === this.task.id
    );
    const addIndex = this.tasks.findIndex(
      (task) => task.id === overTask.id
    );
    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でのタスクの移動は行えません。

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

移動ができない理由は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) {
    const 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") {
    const deleteIndex = this.tasks.findIndex(
      (task) => task.id === this.task.id
    );
    const addIndex = this.tasks.findIndex(
      (task) => task.id === overTask.id
    );
    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") {
    const deleteIndex = this.categories.findIndex(
      (category) => category.id === this.category.id
    );
    const addIndex = this.categories.findIndex(
      (category) => category.id === overCategory.id
    );
    this.categories.splice(deleteIndex, 1);
    this.categories.splice(addIndex, 0, this.category);
  } else {
    if (
      this.task.category_id !== overCategory.id &&
      this.type === "task"
    ) {
      const tasks = this.tasks.filter(
        (task) => task.category_id === overCategory.id
      );
      if (tasks.length === 0) this.task.category_id = overCategory.id;
    }
  }
},

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

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

TypeScriptを利用した場合

現在記事を更新中です。

TypeScriptとComposition APIを利用してコードの書き換えを行っていきます。ここまではindex.htmlファイルの中でcdnからVue.jsを読み込んでいましたがここではcreate-vue toolを利用してVue.jsプロジェクトを作成します。create-vueを利用するとTypeScriptの機能を選択するだけでVue.jsプロジェクトでTypeScriptが利用可能となります。

create-vue toolでプロジェクトを作成するためにはNode.jsとnpmのインストールが必要になります。Node.jsのインストールはWindowsでもMacでも行うことができます。

プロジェクトの作成

Vue.jsプロジェクトを作成するためにnpm init vue@latestコマンドを実行します。


 % npm init vue@latest

実行するとプロジェクト名とプロジェクトに追加する機能を聞かれるので必要な機能を選択してください。TypeScriptを利用する場合はTypeScriptの選択で”yes”を選択してください。プロジェクト名は任意の名前をつけることができるので好きな名前をつけてください。


 % npm init vue@latest
 Need to install the following packages:
  create-vue@latest
Ok to proceed? (y) y

Vue.js - The Progressive JavaScript Framework

√ Project name: ... ts-trello-drag-drop
√ Add TypeScript? ... No / Yes
√ Add JSX Support? ... No / Yes
√ Add Vue Router for Single Page Application development? ... No / Yes
√ Add Pinia for state management? ... No / Yes
√ Add Vitest for Unit Testing? ... No / Yes
√ Add Cypress for both Unit and End-to-End testing? ... No / Yes
√ Add ESLint for code quality? ... No / Yes

プロジェクトの作成が完了したらプロジェクト名で設定した名前でフォルダが作成されるので移動してnpm installコマンドを実行してください。


 % cd ts-trello-drag-drop
 % npm install

npm installコマンドによりJavaScript関連のライブラリのインストールが行われるのでインストールが完了したらnpm run devコマンドを実行して開発サーバは起動します。


 % npm run dev
//略
  > Local: http://localhost:3000/
  > Network: use `--host` to expose

  ready in 608ms.

開発サーバが起動したらブラウザからhttp://localhost:3000/にアクセスすると下記の初期画面が表示されます。Vue.jsでアプリケーションを構築するための環境構築は完了です。

create-vueで作成したVueの初期画面
create-vueで作成したVueの初期画面

Tailwind CSSのインストール

Tailwind CSSのインストールを行います。インストールはTailwind CSSのVue 3とViteでの手順を参考に行っていきます。

Tailwind CSSを利用するために必要となるパッケージのインストールを行い、initコマンドで設定ファイルを作成します。


 % npm install -D tailwindcss postcss autoprefixer
 % npx tailwindcss init -p

プロジェクトフォルダの直下にpostcsss.config.jsとtailwind.config.jsファイルが作成されます。

tailwind.config.jsファイルを開いて以下を追加設定を行ってください。


module.exports = {
  content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
  theme: {
    extend: {},
  },
  plugins: [],
};

index.cssファイルをsrcフォルダの下に作成して以下の設定を追加します。


@tailwind base;
@tailwind components;
@tailwind utilities;

srcフォルダのmain.tsファイルを開いて作成したindex.cssファイルをimportします。


import { createApp } from 'vue'
import App from './App.vue'
import "./index.css";

createApp(App).mount('#app')

Tailwind CSSの設定が完了しているか確認するためにApp.vueファイルに以下のコードを記述します。


<script setup lang="ts"></script>

<template>
  <div class="h-12 p-2">
    <h1 class="text-sm font-bold">Trello風タスク管理</h1>
  </div
</template>

Tailwind CSSのutility classが適用されている場合は以下のように表示され正常に動作していることが確認できます。Tailwind CSSが反映されたない場合はnpm run devコマンドを再実行してください。

Tailwind CSSの動作確認
Tailwind CSSの動作確認

データのimport

データはjsonファイルとして準備します。srcフォルダの下にdataフォルダを作成してcategories.jsonとtasks.jsonファイルを作成します。

各ファイルには以下の内容を記述します。

categories.jsonファイルにはカテゴリーの一覧が含まれています。


[
  {
    "id": 1,
    "name": "テストA",
    "collapsed": false
  },
  {
    "id": 2,
    "name": "テストB",
    "collapsed": false
  },
  {
    "id": 3,
    "name": "テストC",
    "collapsed": false
  }
]

tasks.jsonファイルにはタスクの一覧が含まれています。


[
  {
    "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
  }
]

JSONファイルはApp.vueファイルからimportで読み込むことができます。


import category_data from "./data/catagories.json";
import task_data from "./data/tasks.json";

読み込んだデータはref関数を利用してreactiveなデータとして保存します。ref関数を利用する場合はvueからimportする必要があります。


import { ref } from "vue";
//略
const categories = ref(category_data);
const tasks = ref(task_data);

ref関数の型設定

TypeScriptの型推論により明示的に型の設定を行わなくてもエラーメッセージが表示されれことはありませんが型の設定を行います。

アプリケーションの中で利用する型を管理するためにsrcフォルダにtypesフォルダを作成します。作成しするアプリが小さいのでindex.tsファイルにすべての型を保存してコンポーネントから利用できるようにexportします。imortしたJSONファイルのデータを元にInterfaceを利用してCategoryとTaskを定義しています。InterfaceではなくTypeを利用することも可能です。?をつけたプロパティはオプショナルなのでそのプロパティがなくてもエラーになることはありません。


export interface Category {
  id: number;
  name: string;
  collapsed?: boolean;
}

export interface Task {
  id: number;
  category_id: number;
  name: string;
  start_date: string;
  end_date: string;
  incharge_user: string;
  percentage: number;
}

型の定義ができたのでApp.vueファイルで型をimportします。


import type { Category, Task, CategoryTask } from "./types/";

importした型を使ってref関数に設定を行います。ref関数で型を設定する場合にはジェネリクスを利用して行います。


const categories = ref(category_data);
const tasks = ref(task_data);

Computedプロパティの設定

カテゴリーとタスクデータが別々の配列として保存されているので、カテゴリーデータの中にタスクデータをネスト化します。ネスト化にはcomputedプロパティを利用してrenderCategoryTaskを作成しています。

computedプロパティを利用するためにはvueからimportする必要があります。


import { ref, computed } from "vue";

copmputedプロパティも戻り値をTypeScriptが型推論してくれるため明示的に型を設定する必要はありません。


const renderCategoryTask = computed(() => {
  return categories.value.map((category) => {
    const filterTasks = tasks.value.filter(
      (task) => task.category_id === category.id
    );
    return {
      id: category.id,
      name: category.name,
      tasks: filterTasks,
    };
  });
});

明示的に型を設定したい場合はrefと同様にジェネリクスを利用して設定しますが戻り値の型がCategory, Taskとも異なるので新たにCategoryTaskをindex.tsファイルに追加します。


export interface CategoryTask {
  id: number;
  name: string;
  collapsed?: boolean;
  tasks: Task[];
}

型の設定が完了したらCategoryTaskの型をimportします。


import type { Category, Task, CategoryTask } from "./types/";

CategoryTaskをcomputedプロパティに設定します。


const renderCategoryTask = computed(() => {
  return categories.value.map((category) => {
    const filterTasks = tasks.value.filter(
      (task) => task.category_id === category.id
    );
    return {
      id: category.id,
      name: category.name,
      tasks: filterTasks,
    };
  });
});

ここまでの設定でsctiptタグの中身は下記のようになります。


<script setup lang="ts">
import { ref, computed } from "vue";
import category_data from "./data/catagories.json";
import task_data from "./data/tasks.json";
import type { Category, Task, CategoryTask } from "./types/";

const categories = ref<Category[]>(category_data);
const tasks = ref<Task[]>(task_data);

const renderCategoryTask = computed<CategoryTask[]>(() => {
  return categories.value.map((category) => {
    const filterTasks = tasks.value.filter(
      (task) => task.category_id === category.id
    );
    return {
      id: category.id,
      name: category.name,
      tasks: filterTasks,
    };
  });
});
</script>

ブラウザにデータを描写

computedプロパティを利用して作成したrenderCategoryTaskをv-forディレクティブで展開することでブラウザ上にカテゴリー名を表示させます。


<template>
  <div class="h-12 p-2">
    <h1 class="text-sm font-bold">Trello風タスク管理</h1>
    <div class="flex">
      <div v-for="category in renderCategoryTask" :key="category.id">
        {{ category.name }}
      </div>
    </div>
  </div>
</template>

ブラウザで確認するとカテゴリー名が表示されます。

カテゴリーの表示
カテゴリーの表示

コンポーネントの設定

カテゴリーとタスクの描写部分をコンポーネント化していきます。コンポーネント化することでTypeScriptを使った場合のpropsやemitによるイベントの設定などの理解を深めることができます。

componentsフォルダにCategoryItem.vueファイルを作成します。最初はscriptとtemplateタグのみで何も処理は記述していません。


<script setup lang="ts"></script>
<template></template>

App.vueファイルで作成したCategoryItemファイルからimportしたCategoryItemコンポーネントにpropsでcategoryTaskを渡します。


import CategoryItem from "./components/CategoryItem.vue";

カテゴリーの幅をmin-w-[400px]を設定しています。


<template>
  <div class="h-12 p-2">
    <h1 class="text-sm font-bold">Trello風タスク管理</h1>
    <div class="flex">
      <CategoryItem
        class="min-w-[400px]"
        v-for="categoryTask in renderCategoryTask"
        :key="categoryTask.id"
        :categoryTask="categoryTask"
      />
    </div>
  </div>
</template>

CategoryItem.vueファイルではpropsから渡されたcategoryTaskをdefinePropsを使って設定します。definePropsはimortを行う必要はありません。definePropsではジェネリクスを利用して渡されるpropsの型を指定します。ここではCategoryTaskが型となります。


<script setup lang="ts">
import type { CategoryTask } from "../types/";
defineProps<{
  categoryTask: CategoryTask;
}>();
</script>

interfaceを利用して下記のように書き換えることもできます。


<script setup lang="ts">
import type { CategoryTask } from "../types/";
import TaskItem from "./TaskItem.vue";
interface Props {
  categoryTask: CategoryTask;
}
defineProps();
</script>

propsで受け取ったcategoryTaskを使ってカテゴリー名を表示させます。カテゴリーの背景の色の設定を行っています。


<template>
  <div class="bg-gray-200 m-2 p-2 text-sm">
    <span class="font-bold"> {{ categoryTask.name }}</span>
  </div>
</template>

ブラウザで確認すると下記のように表示されます。

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

タスクの表示についてもTaskItemコンポーネントを作成して行います。TaskItemコンポーネントについてはpropsでtaskを受け取ることができるように以下のように記述します。


<script setup lang="ts">
import type { Task } from "../types/";

defineProps<{
  task: Task;
}>();
</script>
<template>
  <div>
    {{ task.name }}
  </div>
</template>

作成したTaskItemコンポーネントをCategorItem.vueファイルでimportを行います。importしたTaskItemコンポーネントをtemplateタグで利用します。propsにはTaskItem.vueファイルで設定したpropsのtaskでv-forディレクティブで展開したtaskを渡します。


<script setup lang="ts">
import type { CategoryTask } from "../types/";
import TaskItem from "./TaskItem.vue";
defineProps<{
  categoryTask: CategoryTask;
}>();
</script>
<template>
  <div class="bg-gray-200 m-2 p-2 text-sm">
    <span class="font-bold"> {{ categoryTask.name }}</span>
    <TaskItem
      v-for="task in categoryTask.tasks"
      :key="task.id"
      :task="task"
      class="m-2 bg-white p-2"
    />
  </div>
</template>
カテゴリーとタスクを表示
カテゴリーとタスクを表示

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

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

draggableの設定

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

draggable属性を追加してtrueに設定すると要素をDragすることが可能になります。Dragはタスクをクリックすると要素を掴むことができクリックボタンを押している間は要素を移動することができます。


<TaskItem
  v-for="task in categoryTask.tasks"
  :key="task.id"
  :task="task"
  class="m-2 bg-white p-2"
  draggable="true"
/>
draggableを設定するとは
draggableを設定するとは

draggable属性を設定することで要素をdragできることがわかったので次はdragstartイベントを設定します。dragstartイベントを設定すると要素をDrag(マウスでクリックして掴む)した直後にイベントが発生し指定した処理を行うことができます。dragstartイベントにsetDragTaskメソッドを設定し動作確認を行います。イベントの設定にはv-onディレクティブを利用することができここでは略称表記の@を利用しています。


<TaskItem
  v-for="task in categoryTask.tasks"
  :key="task.id"
  :task="task"
  class="m-2 bg-white p-2"
  draggable="true"
  @dragstart="setdragTask"
/>

scriptタグの中にsetDragTask関数を追加します。


const setDragTask = () => {
  console.log("drag");
};;

設定後タスク要素をDragするとブラウザのデベロッパーツールのコンソールに”drag”の文字列が表示されます。どのタスク要素をDragしてもDragを開始した直後にのみ”drag”の文字列が表示れます。

本アプリケーションではタスク要素をDragして別のタスク要素の上に移動するとその要素の位置にDragした要素が移動します。機能を実現するためにDragするタスクの要素を一度tasks配列の元々存在してい場所から削除し、移動先の要素が存在する配列の位置に削除たタスク要素を追加します。タスクを追加するためにタスクの情報を保持していく必要があります。タスク情報を保持する変数dragTaskをAppコンポーネントでref関数を利用して定義します。

dragTaskの初期値はnullですがタスク情報を保持するのでnullとTaskのunion型を設定します。


const categories = ref<Category[]>(category_data);
const tasks = ref<Task[]>(task_data);
const dragTask = ref<Task | null>(null);

emitの設定

dragTaskへの値の設定はemitを利用して行います。Vue.jsでは親コンポーネントから子コンポーネントに値を渡す時はpropsを利用しますが子コンポーネントから親コンポーネントに値を渡す場合にはemitを利用します。CategoryItemコンポーネントのdragstartイベントでemitを実行するためemitの設定を行う必要があります。

emitはdefineEmits関数で定義します。ジェネリクスの中で関数の型定義を行います。引数ではイベント名とemitを利用して親コンポーネントに渡す値の型を設定します。戻り値はないもないのでvoidを設定しています。


const emit = defineEmits<{
  (e: "setDragTask", task: Task): void;
}>();

const setDragTask = (task: Task) => {
  emit("setDragTask", task);
};

emitによって発生したsetDragTaskイベントをAppコンポーネントで受け取る必要があります。v-onディレクティブとイベント名を利用して受け取ることができます。v-onは短縮形の@に変更します。


<CategoryItem
  class="min-w-[400px]"
  v-for="categoryTask in renderCategoryTask"
  :key="categoryTask.id"
  :categoryTask="categoryTask"
  @setDragTask="setDragTask"
/>

setDragTaskイベントを受け取るとsetDragTask関数を実行します。setDragTask関数で子コンポーネントから送られてきたtaskをdragTaskに保存します。


const setDragTask = (task: Task) => {
  dragTask.value = task;
};

ここまででemitを利用したDragしたタスクの情報をAppコンポーネントで定義したdragTaskに保存することができるようになりました。

Dragした要素の移動処理

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

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

dragoverイベントはCategoryItemコンポーネントで設定を行います。dragoverイベントにdragOverTask関数を設定し引数にはtaskを指定します。


<TaskItem
  v-for="task in categoryTask.tasks"
  :key="task.id"
  :task="task"
  class="m-2 bg-white p-2"
  draggable="true"
  @dragstart="setdragTask(task)"
  @dragover="dragOverTask(task)"
/>

taskは親コンポーネントに渡す必要があるので先ほどのdragstartイベントと同様にemitを利用します。emitではイベント名をdragOverTaskとして設定します。


const emit = defineEmits<{
  (e: "setDragTask", task: Task): void;
  (e: "dragOverTask", task: Task): void;
}>();

const setDragTask = (task: Task) => {
  emit("setDragTask", task);
};

const dragOverTask = (task: Task) => {
  emit("dragOverTask", task);
};

親コンポーネントではdragOverTaskイベントを受け取る設定を行います。方法はsetDragTaskと同じです。dragOverTaskイベントを受け取ったらdragOverTask関数を実行するのでdragOverTaskを追加します。


<CategoryItem
  class="min-w-[400px]"
  v-for="categoryTask in renderCategoryTask"
  :key="categoryTask.id"
  :categoryTask="categoryTask"
  @setDragTask="setDragTask"
  @dragOverTask="dragOverTask"
/>

dragOverTask関数の中では動作確認のため渡されたoverTaskの情報とdragstart時に設定したdragTaskの中身を確認します。


const dragOverTask = (overTask: Task) => {
  console.log("task:", dragTask.value);
  console.log("overTask:", overTask);
};

動作確認を行うとブラウザのデベロッパーツールのコンソールにDragしたタスクの情報とDragしたタスク要素の下にあるタスクの情報が表示されます。

Dragしているタスク情報とDragしたタスク要素の下にあるタスク情報が取得できたのでタスクの削除と追加処理をdragOverTask関数の中で記述します。


const dragOverTask = (overTask: Task) => {
  if (dragTask.value?.id !== overTask.id) {
    const deleteIndex = tasks.value.findIndex(
      (task) => task.id === dragTask.value?.id
    );
    const addIndex = tasks.value.findIndex((task) => task.id === overTask.id);
    if (dragTask.value !== null) {
      tasks.value.splice(deleteIndex, 1);
      tasks.value.splice(addIndex, 0, dragTask.value);
    }
  }
};

dragTaskのidとoverTaskのidの値が異なる場合のみ移動の処理を実行します。dragTask.valueに?がついているのはdragTaskの値がnullの場合もあるため?をつけないとエラーメッセージが表示されます。

上記の設定で同じカテゴリー内であればタスク要素の移動ができるようになります。

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

現在の設定では同じカテゴリー内であればタスク要素の移動はできますが別のカテゴリーへタスク要素をDragしてもタスク要素の移動を行うことができません。

別のカテゴリーへのタスクの移動できるように更新します。

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


const dragOverTask = (overTask: Task) => {
  if (dragTask.value?.id !== overTask.id) {
    const deleteIndex = tasks.value.findIndex(
      (task) => task.id === dragTask.value?.id
    );
    const addIndex = tasks.value.findIndex((task) => task.id === overTask.id);
    if (dragTask.value !== null) {
      tasks.value.splice(deleteIndex, 1);
      dragTask.value.category_id = overTask.category_id; //追加
      tasks.value.splice(addIndex, 0, dragTask.value);
    }
  }
};

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

カテゴリー間の移動
カテゴリー間の移動

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

カテゴリー間の移動を行えるようになりましたが下記のようにカテゴリーテストCにタスクがない場合はDragする先にタスク要素がないので移動処理を行うことができません。

カテゴリーにタスクがない場合
カテゴリーにタスクがない場合

移動を行う際にはdragoverイベントを設定したタスク要素が必要になります。タスク要素がなくても移動できるようにカテゴリー要素にdragoverイベントを設定します。設定はAppコンポーネントのCategoryItemタグで行います。


<CategoryItem
  class="min-w-[400px]"
  v-for="categoryTask in renderCategoryTask"
  :key="categoryTask.id"
  :categoryTask="categoryTask"
  @dragover="dragOverCategory(categoryTask)"
  @setDragTask="setDragTask"
  @dragOverTask="dragOverTask"
/>

dragoverイベントにdragOverCategory関数を設定し引数にはcategoryTaskを指定しています。

dragOverCategory関数を追加します。


const dragOverCategory = (categoryTask: CategoryTask) => {
  if (dragTask.value?.category_id !== categoryTask.id) {
    const filterTasks = tasks.value.filter(
      (task) => task.category_id === categoryTask.id
    );
    if (filterTasks.length === 0 && dragTask.value !== null)
      dragTask.value.category_id = categoryTask.id;
  }
};

Dragした要素を保存したdragTaskのcategory_idと移動先のカテゴリーidが一致しないことをチェックし、移動先のカテゴリーにタスク要素がない場合のみdragTaskのcategory_idに移動先のカテゴリーのidを設定します。

これでタスク要素がないカテゴリーへの移動を行うことができるようになりました。