Backlog 開発チームの saki です。仕事は Scala と Java ですが、Ruby や JavaScript などのいわゆる動的型付け言語も好きです。最近はフロントエンドに興味がありプライベートで Vue.js を触っています。
Vue はプログレッシブフレームワークを謳っており、その名の通り最初は小さく導入でき、ソフトウェアの成長に合わせて他のライブラリを組み合わせることで規模の大きい開発にも利用できる柔軟さが気に入りました。ドキュメントも充実しており学びやすいと思います。どのライブラリも自分が一番と宣伝する中で、他のフレームワークとの比較 のページには誠意を感じました。
今回はそんな Vue を使って、かんばんライクなタスク管理アプリの簡単な作り方を紹介します。アウトラインは以下です。
CSS framework には Bulma を使います。選んだ理由は、Web Design in 4 minutes の作者が書いていてなんとなく好きだからです。
最終的に作るアプリのイメージは以下です。未対応、処理中、完了の状態別にタスクを表すカードを縦に並べ、カードではアサインされたメンバーと工数の情報を表示します。

完成形は jsfiddle にてご覧いただけます。
基礎の作成
はじめに、以下ライブラリを読む html ファイルを用意して下さい。
- Vue.js 2.5.2
- cdn: https://unpkg.com/vue@2.5.2/dist/vue.js
- Bulma 0.6.0
- cdn: https://cdnjs.cloudflare.com/ajax/libs/bulma/0.6.0/css/bulma.min.css
次に、アプリの基礎となる Vue インスタンスを作成します。ここでは el でインスタンスが対応するDOM 要素を指定し、data でアプリ内で扱うデータを記述します。status の数値と状態の対応は、1: 未対応 2: 処理中 3: 完了とします。
{ name: 'task 1', status: 1, assignee: '🐱', mandays: 3 },
{ name: 'task 2', status: 1, assignee: '🐶', mandays: 2 },
{ name: 'task 3', status: 2, assignee: '🐱', mandays: 1 },
{ name: 'task 4', status: 3, assignee: '🐹', mandays: 1 }
new Vue({
el: '#board',
data: {
tasks: [
{ name: 'task 1', status: 1, assignee: '🐱', mandays: 3 },
{ name: 'task 2', status: 1, assignee: '🐶', mandays: 2 },
{ name: 'task 3', status: 2, assignee: '🐱', mandays: 1 },
{ name: 'task 4', status: 3, assignee: '🐹', mandays: 1 }
]
}
})
new Vue({
el: '#board',
data: {
tasks: [
{ name: 'task 1', status: 1, assignee: '🐱', mandays: 3 },
{ name: 'task 2', status: 1, assignee: '🐶', mandays: 2 },
{ name: 'task 3', status: 2, assignee: '🐱', mandays: 1 },
{ name: 'task 4', status: 3, assignee: '🐹', mandays: 1 }
]
}
})
ロジックから作ってもいいですが、目に見えるものがあった方が安心できるので、まずはインスタンス内のデータを表示する画面を作ります。
<section class="section" id="board">
<div class="column status-1">
<div class="tags has-addons">
<span class="tag">未対応</span>
<span class="tag is-dark">{{ tasks.length }}</span>
<div class="card" v-for="task in tasks" v-bind:key="task.name">
<div class="card-content">{{ task.name }}</div>
<div class="card-footer">
<div class="card-footer-item">{{ task.assignee }}</div>
<div class="card-footer-item">{{ task.mandays }} 人日</div>
<div class="card-footer">
<a class="card-footer-item">◀︎</a>
<a class="card-footer-item">▶︎</a>
<div class="column status-2">
<div class="tags has-addons">
<span class="tag">処理中</span>
<span class="tag is-dark"></span>
<div class="column status-3">
<div class="tags has-addons">
<span class="tag">完了</span>
<span class="tag is-dark"></span>
<section class="section" id="board">
<div class="container">
<div class="columns">
<div class="column status-1">
<div class="tags has-addons">
<span class="tag">未対応</span>
<span class="tag is-dark">{{ tasks.length }}</span>
</div>
<div class="card" v-for="task in tasks" v-bind:key="task.name">
<div class="card-content">{{ task.name }}</div>
<div class="card-footer">
<div class="card-footer-item">{{ task.assignee }}</div>
<div class="card-footer-item">{{ task.mandays }} 人日</div>
</div>
<div class="card-footer">
<a class="card-footer-item">◀︎</a>
<a class="card-footer-item">▶︎</a>
</div>
</div>
</div>
<div class="column status-2">
<div class="tags has-addons">
<span class="tag">処理中</span>
<span class="tag is-dark"></span>
</div>
</div>
<div class="column status-3">
<div class="tags has-addons">
<span class="tag">完了</span>
<span class="tag is-dark"></span>
</div>
</div>
</div>
</div>
</section>
<section class="section" id="board">
<div class="container">
<div class="columns">
<div class="column status-1">
<div class="tags has-addons">
<span class="tag">未対応</span>
<span class="tag is-dark">{{ tasks.length }}</span>
</div>
<div class="card" v-for="task in tasks" v-bind:key="task.name">
<div class="card-content">{{ task.name }}</div>
<div class="card-footer">
<div class="card-footer-item">{{ task.assignee }}</div>
<div class="card-footer-item">{{ task.mandays }} 人日</div>
</div>
<div class="card-footer">
<a class="card-footer-item">◀︎</a>
<a class="card-footer-item">▶︎</a>
</div>
</div>
</div>
<div class="column status-2">
<div class="tags has-addons">
<span class="tag">処理中</span>
<span class="tag is-dark"></span>
</div>
</div>
<div class="column status-3">
<div class="tags has-addons">
<span class="tag">完了</span>
<span class="tag is-dark"></span>
</div>
</div>
</div>
</div>
</section>
Vue では html ベースのテンプレート構文を使います。id=”board” を指定した要素の中で二重中括弧で囲んだ構文を使うと、先に作成したインスタンス内のデータを参照できます。9行目にて v-for=”task in tasks” とありますが、ここでは tasks 配列の要素を task に入れ、次の行の {{ task.name }} 等で参照しています。
css は以下とします。
background-color: #ed8077;
background-color: #4488c5;
background-color: #5eb5a6;
.status-1 {
background-color: #ed8077;
}
.status-2 {
background-color: #4488c5;
}
.status-3 {
background-color: #5eb5a6;
}
.card {
margin-bottom: 5px;
}
.card-footer-item {
padding-top: 0px;
padding-bottom: 0px;
}
.status-1 {
background-color: #ed8077;
}
.status-2 {
background-color: #4488c5;
}
.status-3 {
background-color: #5eb5a6;
}
.card {
margin-bottom: 5px;
}
.card-footer-item {
padding-top: 0px;
padding-bottom: 0px;
}
ここまでで以下のような画面が表示できました。

目に見えるものができ安心できましたが、タスクの状態を考慮せず全て未対応のレーンに表示しています。状態別に抽出する処理を作成し、直しましょう。以下のようなフィルタを書きます。
return tasks.filter(function (task) {
doing: function (tasks) {
return tasks.filter(function (task) {
closed: function (tasks) {
return tasks.filter(function (task) {
var filters = {
open: function (tasks) {
return tasks.filter(function (task) {
return task.status === 1
})
},
doing: function (tasks) {
return tasks.filter(function (task) {
return task.status === 2
})
},
closed: function (tasks) {
return tasks.filter(function (task) {
return task.status === 3
})
}
}
var filters = {
open: function (tasks) {
return tasks.filter(function (task) {
return task.status === 1
})
},
doing: function (tasks) {
return tasks.filter(function (task) {
return task.status === 2
})
},
closed: function (tasks) {
return tasks.filter(function (task) {
return task.status === 3
})
}
}
Vue インスタンスからこれを呼ぶには算出プロパティを使います。computed: {} 内で以下のように関数を定義することで、Vue インスタンスで tasks データが更新された際に再評価されます。
{ name: 'task 1', status: 1, assignee: '🐱', mandays: 3 },
{ name: 'task 2', status: 1, assignee: '🐶', mandays: 2 },
{ name: 'task 3', status: 2, assignee: '🐱', mandays: 1 },
{ name: 'task 4', status: 3, assignee: '🐹', mandays: 1 }
return filters.open(this.tasks)
tasksDoing: function () {
return filters.doing(this.tasks)
tasksClosed: function () {
return filters.closed(this.tasks)
new Vue({
el: '#board',
data: {
tasks: [
{ name: 'task 1', status: 1, assignee: '🐱', mandays: 3 },
{ name: 'task 2', status: 1, assignee: '🐶', mandays: 2 },
{ name: 'task 3', status: 2, assignee: '🐱', mandays: 1 },
{ name: 'task 4', status: 3, assignee: '🐹', mandays: 1 }
]
},
computed: {
tasksOpen: function () {
return filters.open(this.tasks)
},
tasksDoing: function () {
return filters.doing(this.tasks)
},
tasksClosed: function () {
return filters.closed(this.tasks)
}
}
})
new Vue({
el: '#board',
data: {
tasks: [
{ name: 'task 1', status: 1, assignee: '🐱', mandays: 3 },
{ name: 'task 2', status: 1, assignee: '🐶', mandays: 2 },
{ name: 'task 3', status: 2, assignee: '🐱', mandays: 1 },
{ name: 'task 4', status: 3, assignee: '🐹', mandays: 1 }
]
},
computed: {
tasksOpen: function () {
return filters.open(this.tasks)
},
tasksDoing: function () {
return filters.doing(this.tasks)
},
tasksClosed: function () {
return filters.closed(this.tasks)
}
}
})
html 側で tasks を tasksOpen へ書き換えると未対応の課題のみ表示できました。

コンポーネントの作成
続いて各レーンにタスクを表示したいところですが、このまま class=”card” 要素をレーンを表す <div class=”column status-*”> 配下にコピペするとあとで改修が大変そうです。Vue ではコンポーネント機能を使うことで、再利用可能なパーツを作ることができます。task-card コンポーネントを作りましょう。
コンポーネントは以下のように作ります。template プロパティへ class=”card” 配下の html を移しました。props プロパティは task-card コンポーネントへデータを受け渡すのに使います。
Vue.component('task-card', {
template: `<div class="card">
<div class="card-content">
<footer class="card-footer">
<div class="card-footer-item">
<div class="card-footer-item">
<footer class="card-footer">
<a class="card-footer-item">◀︎</a>
<a class="card-footer-item">▶︎</a>
Vue.component('task-card', {
props: ['task'],
template: `<div class="card">
<div class="card-content">
{{ task.name }}
</div>
<footer class="card-footer">
<div class="card-footer-item">
{{ task.assignee }}
</div>
<div class="card-footer-item">
{{ task.mandays }} 人日
</div>
</footer>
<footer class="card-footer">
<a class="card-footer-item">◀︎</a>
<a class="card-footer-item">▶︎</a>
</footer>
</div>`
})
Vue.component('task-card', {
props: ['task'],
template: `<div class="card">
<div class="card-content">
{{ task.name }}
</div>
<footer class="card-footer">
<div class="card-footer-item">
{{ task.assignee }}
</div>
<div class="card-footer-item">
{{ task.mandays }} 人日
</div>
</footer>
<footer class="card-footer">
<a class="card-footer-item">◀︎</a>
<a class="card-footer-item">▶︎</a>
</footer>
</div>`
})
task-card コンポーネントを利用するには以下のようにします。
<section class="section" id="board">
<div class="column status-1">
<div class="tags has-addons">
<span class="tag">未対応</span>
<span class="tag is-dark">{{ tasksOpen.length }}</span>
<task-card v-bind:task="task" v-for="task in tasksOpen" v-bind:key="task.name"></task-card>
<div class="column status-2">
<div class="tags has-addons">
<span class="tag">処理中</span>
<span class="tag is-dark">{{ tasksDoing.length }}</span>
<task-card v-bind:task="task" v-for="task in tasksDoing" v-bind:key="task.name"></task-card>
<div class="column status-3">
<div class="tags has-addons">
<span class="tag">完了</span>
<span class="tag is-dark">{{ tasksClosed.length }}</span>
<task-card v-bind:task="task" v-for="task in tasksClosed" v-bind:key="task.name"></task-card>
<section class="section" id="board">
<div class="container">
<div class=columns>
<div class="column status-1">
<div class="tags has-addons">
<span class="tag">未対応</span>
<span class="tag is-dark">{{ tasksOpen.length }}</span>
</div>
<task-card v-bind:task="task" v-for="task in tasksOpen" v-bind:key="task.name"></task-card>
</div>
<div class="column status-2">
<div class="tags has-addons">
<span class="tag">処理中</span>
<span class="tag is-dark">{{ tasksDoing.length }}</span>
</div>
<task-card v-bind:task="task" v-for="task in tasksDoing" v-bind:key="task.name"></task-card>
</div>
<div class="column status-3">
<div class="tags has-addons">
<span class="tag">完了</span>
<span class="tag is-dark">{{ tasksClosed.length }}</span>
</div>
<task-card v-bind:task="task" v-for="task in tasksClosed" v-bind:key="task.name"></task-card>
</div>
</div>
</div>
</section>
<section class="section" id="board">
<div class="container">
<div class=columns>
<div class="column status-1">
<div class="tags has-addons">
<span class="tag">未対応</span>
<span class="tag is-dark">{{ tasksOpen.length }}</span>
</div>
<task-card v-bind:task="task" v-for="task in tasksOpen" v-bind:key="task.name"></task-card>
</div>
<div class="column status-2">
<div class="tags has-addons">
<span class="tag">処理中</span>
<span class="tag is-dark">{{ tasksDoing.length }}</span>
</div>
<task-card v-bind:task="task" v-for="task in tasksDoing" v-bind:key="task.name"></task-card>
</div>
<div class="column status-3">
<div class="tags has-addons">
<span class="tag">完了</span>
<span class="tag is-dark">{{ tasksClosed.length }}</span>
</div>
<task-card v-bind:task="task" v-for="task in tasksClosed" v-bind:key="task.name"></task-card>
</div>
</div>
</div>
</section>
v-bind:task=”task” では、task-card コンポーネントの props にtaskのデータを渡しています。コンポーネント機能を使うことで、ボードの html からは task-card の実装の詳細を隠蔽でき、見通しがよくなりました。

状態変更処理の作成
画面が大体できたので、タスクの状態を変える機能を実装しましょう。methods: {} 内でタスクの状態の値を増減するメソッドを定義し、task-card の下部の◀︎▶︎をクリックすると、それぞれの処理を呼ぶようにします。v-on でイベントハンドラを付与できます。
Vue.component('task-card', {
template: `<div class="card">
<div class="card-content">
<footer class="card-footer">
<div class="card-footer-item">
<div class="card-footer-item">
<footer class="card-footer">
<a class="card-footer-item" v-on:click="decrementStatus(task)">◀︎</a>
<a class="card-footer-item" v-on:click="incrementStatus(task)">▶︎</a>
incrementStatus: function (task) {
if(1 <= task.status && task.status <= 2) {
decrementStatus: function (task) {
if(2 <= task.status && task.status <= 3) {
Vue.component('task-card', {
props: ['task'],
template: `<div class="card">
<div class="card-content">
{{ task.name }}
</div>
<footer class="card-footer">
<div class="card-footer-item">
{{ task.assignee }}
</div>
<div class="card-footer-item">
{{ task.mandays }} 人日
</div>
</footer>
<footer class="card-footer">
<a class="card-footer-item" v-on:click="decrementStatus(task)">◀︎</a>
<a class="card-footer-item" v-on:click="incrementStatus(task)">▶︎</a>
</footer>
</div>`,
methods: {
incrementStatus: function (task) {
if(1 <= task.status && task.status <= 2) {
task.status++
}
},
decrementStatus: function (task) {
if(2 <= task.status && task.status <= 3) {
task.status--
}
}
}
})
Vue.component('task-card', {
props: ['task'],
template: `<div class="card">
<div class="card-content">
{{ task.name }}
</div>
<footer class="card-footer">
<div class="card-footer-item">
{{ task.assignee }}
</div>
<div class="card-footer-item">
{{ task.mandays }} 人日
</div>
</footer>
<footer class="card-footer">
<a class="card-footer-item" v-on:click="decrementStatus(task)">◀︎</a>
<a class="card-footer-item" v-on:click="incrementStatus(task)">▶︎</a>
</footer>
</div>`,
methods: {
incrementStatus: function (task) {
if(1 <= task.status && task.status <= 2) {
task.status++
}
},
decrementStatus: function (task) {
if(2 <= task.status && task.status <= 3) {
task.status--
}
}
}
})
クリックで、課題の状態を変更できるようになりました。

タスク追加機能の作成
タスク管理アプリとして成立させるため、タスク追加機能を作ります。html 側で入力フォームを追加し、js 側で課題の追加処理を実装しましょう。
今回は素朴に、Vue インスタンスで data に newTaskName, newTaskAssignee, newTaskMandays を追加し、それらと入力フォームを対応させます。
js の実装は以下になります。methods: {} 内にタスクを追加する関数を定義しました。
{ name: 'task 1', status: 1, assignee: '🐱', mandays: 3 },
{ name: 'task 2', status: 1, assignee: '🐶', mandays: 2 },
{ name: 'task 3', status: 2, assignee: '🐱', mandays: 1 },
{ name: 'task 4', status: 3, assignee: '🐹', mandays: 1 }
return filters.open(this.tasks)
tasksDoing: function () {
return filters.doing(this.tasks)
tasksClosed: function () {
return filters.closed(this.tasks)
this.tasks.push({ name: this.newTaskName, status: 1, assignee: this.newTaskAssignee, mandays: this.newTaskMandays })
new Vue({
el: '#board',
data: {
tasks: [
{ name: 'task 1', status: 1, assignee: '🐱', mandays: 3 },
{ name: 'task 2', status: 1, assignee: '🐶', mandays: 2 },
{ name: 'task 3', status: 2, assignee: '🐱', mandays: 1 },
{ name: 'task 4', status: 3, assignee: '🐹', mandays: 1 }
],
newTaskName: '',
newTaskAssignee: null,
newTaskMandays: 0
},
computed: {
tasksOpen: function () {
return filters.open(this.tasks)
},
tasksDoing: function () {
return filters.doing(this.tasks)
},
tasksClosed: function () {
return filters.closed(this.tasks)
}
},
methods: {
addTask () {
this.tasks.push({ name: this.newTaskName, status: 1, assignee: this.newTaskAssignee, mandays: this.newTaskMandays })
}
}
})
new Vue({
el: '#board',
data: {
tasks: [
{ name: 'task 1', status: 1, assignee: '🐱', mandays: 3 },
{ name: 'task 2', status: 1, assignee: '🐶', mandays: 2 },
{ name: 'task 3', status: 2, assignee: '🐱', mandays: 1 },
{ name: 'task 4', status: 3, assignee: '🐹', mandays: 1 }
],
newTaskName: '',
newTaskAssignee: null,
newTaskMandays: 0
},
computed: {
tasksOpen: function () {
return filters.open(this.tasks)
},
tasksDoing: function () {
return filters.doing(this.tasks)
},
tasksClosed: function () {
return filters.closed(this.tasks)
}
},
methods: {
addTask () {
this.tasks.push({ name: this.newTaskName, status: 1, assignee: this.newTaskAssignee, mandays: this.newTaskMandays })
}
}
})
html 側では上部に入力フォームを追加します。ここで、v-model を使い、対応する Vue インスタンスのデータを指定します。こうすることでフォームの値の変更を Vue インスタンス内のデータに反映できます。
<section class="section" id="board">
<input type="text" v-model="newTaskName" />
<select v-model="newTaskAssignee">
<option value="🐱">🐱</option>
<option value="🐶">🐶</option>
<option value="🐹">🐹</option>
<input type="number" v-model="newTaskMandays" />
<button @click="addTask">追加</button>
<section class="section" id="board">
<div class="container">
<input type="text" v-model="newTaskName" />
<select v-model="newTaskAssignee">
<option value="🐱">🐱</option>
<option value="🐶">🐶</option>
<option value="🐹">🐹</option>
</select>
<input type="number" v-model="newTaskMandays" />
<button @click="addTask">追加</button>
<hr>
<section class="section" id="board">
<div class="container">
<input type="text" v-model="newTaskName" />
<select v-model="newTaskAssignee">
<option value="🐱">🐱</option>
<option value="🐶">🐶</option>
<option value="🐹">🐹</option>
</select>
<input type="number" v-model="newTaskMandays" />
<button @click="addTask">追加</button>
<hr>
ついに課題を追加できるようになりました。

状態変更時のトランジション追加
タスクの状態変更と、追加ができるようになりましたが、DOM の更新が素早く何が起きたかわかりづらいです。Vue ではトランジション機能を使うことで DOM 更新時にエフェクトを追加することができます。
css 側でスタイルを定義し、html 側で適用したい箇所を transition タグで囲むと DOM 更新時にエフェクトをあてることができます。今回は、v-for を使いリストのアイテムを表示する箇所で使うため、transition-group タグを使います。
.fade-enter-active, .fade-leave-active {
.fade-enter, .fade-leave-to {
.fade-enter-active, .fade-leave-active {
transition: opacity 0.7s
}
.fade-enter, .fade-leave-to {
opacity: 0
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.7s
}
.fade-enter, .fade-leave-to {
opacity: 0
}
<section class="section">
<div class=columns id="board">
<div class="column status-1">
<transition-group name="fade">
<task-card :task="task" v-for="task in tasksOpen" key="task"></task-card>
<div class="column status-2">
<transition-group name="fade">
<task-card :task="task" v-for="task in tasksDoing" key="task"></task-card>
<div class="column status-3">
<transition-group name="fade">
<task-card :task="task" v-for="task in tasksClosed" key="task"></task-card>
<section class="section">
<div class="container">
<div class=columns id="board">
<div class="column status-1">
<transition-group name="fade">
<task-card :task="task" v-for="task in tasksOpen" key="task"></task-card>
</transition-group>
</div>
<div class="column status-2">
<transition-group name="fade">
<task-card :task="task" v-for="task in tasksDoing" key="task"></task-card>
</transition-group>
</div>
<div class="column status-3">
<transition-group name="fade">
<task-card :task="task" v-for="task in tasksClosed" key="task"></task-card>
</transition-group>
</div>
</div>
</div>
</section>
<section class="section">
<div class="container">
<div class=columns id="board">
<div class="column status-1">
<transition-group name="fade">
<task-card :task="task" v-for="task in tasksOpen" key="task"></task-card>
</transition-group>
</div>
<div class="column status-2">
<transition-group name="fade">
<task-card :task="task" v-for="task in tasksDoing" key="task"></task-card>
</transition-group>
</div>
<div class="column status-3">
<transition-group name="fade">
<task-card :task="task" v-for="task in tasksClosed" key="task"></task-card>
</transition-group>
</div>
</div>
</div>
</section>
トランジションを使うことで、状態を更新した際に変更される DOM がわかりやすくなりました。
※デモのためあえてエフェクトの時間を長くしています。

まとめ
Vue を使った、簡単なかんばんライクなタスク管理アプリの作り方を紹介しました。状態別に工数を集計したり、バックエンドでのデータ保存や、レーンのコンポーネント化等を進めるとより使えるものになると思います。
私は趣味で同僚と Backlog のデスクトップアプリを作っているのですが、かんばん形式の課題管理は進行が直感的にわかるのと、課題の追加とステータス変更が気軽になり、使い方によっては便利だなと思いました。以下は開発中の画面です。

ヌーラボではタスク管理アプリケーションの開発に興味あるエンジニアの応募をお待ちしています。