スクラッチから作るTrello風タスク管理アプリ タスク追加/更新編
本記事は、”Vue.jsを使ってスクラッチから作るTrello風タスク管理アプリ”の2回目の記事です。前回の記事ではタスクとカテゴリー(カラム)のDrag&Drop機能の実装を行いました。
本文書ではタスク、カテゴリーの追加・更新機能の実装方法について順番に説明を行っています。シングルページアプリケーションなので追加、更新のために別ページに移動する必要はありません。入門者でもすぐに動作確認がおこなえるようにindex.htmlファイルのみでアプリケーションを作成しています。
- カテゴリーの追加
- カテゴリー名の更新
- タスクの追加
- タスクの更新
目次
カテゴリーの追加
新規カテゴリーの追加はflexboxを使って横並びに配置したカテゴリーの最後にカテゴリーの入力領域を追加します。
<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"
@dragstart.self="dragCategory(category)"
@dragover.prevent="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.prevent="dragOverTask(task)"
draggable=true >
{{ task.name}}
</div>
</div>
</div>
<!-- 以下を追加 -->
<div style="min-width:400px">
<div class="bg-gray-200 m-2 p-2 text-sm">
<div>カテゴリーを追加</div>
</div>
</div>
</div>
ブラウザで確認するとテストCの後にカテゴリー追加のコラムが追加されます。
追加領域に表示されている”カテゴリー追加”の文字列をクリックするとInput要素が表示されるようにv-ifディレクティブとclickイベントを利用します。
表示・非表示を制御するデータプロパティshow_category_inputを追加します。
data() {
return {
task:'',
category:'',
type:'',
show_category_input: false,
//略
show_category_inputがfalseの場合は”カテゴリー追加”の文字列を表示し、trueの場合はinput要素を表示させます。clickイベントを利用して”カテゴリー追加”の文字列をクリックするとshow_category_inputの値をtrueにして、input要素を表示しています。
<div style="min-width:400px">
<div class="bg-gray-200 m-2 p-2 text-sm">
<div
v-if="!show_category_input"
@click="show_category_input=true"
>カテゴリーを追加</div>
<div v-else>
<input
type="text"
class="w-full p-2"
placeholder="新しいカテゴリー名を追加してください"
/>
</div>
</div>
</div>
設定後に”カテゴリーを追加”をクリックすると以下のようにinput要素が表示されます。ここまでの設定ではinput要素は表示されましたが、input要素に入力した値を反映させることも一度表示したinput要素を非表示にすることはできません。
追加、キャンセルボタンの追加
入力した値を反映させるための”追加”ボタンと表示されているinput要素を非表示にするための”キャンセル”ボタンをinput要素の下に追加します。
<input
type="text"
class="w-full p-2"
placeholder="新しいカテゴリー名を追加してください"
/>
<div class="flex m-2">
<button class="px-4 py-2 bg-green-500 hover:bg-green-700 text-white rounded-lg mr-2 font-bold">
追加
</button>
<button class="px-4 py-2 bg-red-500 hover:bg-red-700 text-white rounded-lg font-bold">
キャンセル
</button>
</div>
</div>
設定後に”カテゴリーを追加”をクリックすると以下のようにinput要素の下に”追加”ボタンと”キャンセル”ボタンが表示されます。
それぞれのボタンにclickイベントを設定します。追加ボタンのclickイベントにはcategoryAddメソッド、キャンセルボタンのclickイベントにはcloseCategoryInputメソッドを設定しています。
<button
class="px-4 py-2 bg-green-500 hover:bg-green-700 text-white rounded-lg mr-2 font-bold"
@click="categoryAdd">
追加
</button>
<button
class="px-4 py-2 bg-red-500 hover:bg-red-700 text-white rounded-lg font-bold"
@click="closeCategoryInput">
キャンセル
</button>
input要素に入力した値を保持できるようにデータプロパティcategory_nameを追加します。
data() {
return {
task:'',
category:'',
type:'',
category_name,'',
show_category_input: false,
//略
input要素にv-modelを追加し、category_nameを設定します。
<input
type="text"
class="w-full p-2"
placeholder="新しいカテゴリー名を追加してください"
v-model="category_name"
/>;
メソッドの設定
追加、キャンセルボタンのclickイベントに設定したcategoryAdd, closeCategoryInputをVue.jsのmethodsに追加します。idは一意の数字を設定する必要がありますがここでは簡易的にDate.now()を利用しています。
categoryAddメソッドでは入力した値がある場合のみcategoriesの配列にpushメソッドで追加しています。closeCategoryInputメソッドでは入力した値をクリアして、show_category_inputの値をfalseにしてinput要素を非表示にしています。
categoryAdd() {
if (this.category_name != '') {
this.categories.push({
id: Date.now(),
name: this.category_name
}),
this.show_category_input = false;
}
},
closeCategoryInput() {
this.category_name = '';
this.show_category_input = false;
},
”カテゴリー追加”をクリックして表示されるinput要素に”テストD”を入力します。
追加ボタンを押すとテストDのカテゴリーが追加され、その右側にはカテゴリーの追加を行える”カテゴリーを追加”が表示されます。
追加したカテゴリーに既存のタスクを移動することも追加カテゴリーをDrag&Dropで移動させることも可能です。
カテゴリー名の更新
新規のカテゴリーを追加することができたので今度は既存のカテゴリー名の更新方法を実装します。
コンポーネントの定義方法
カテゴリー名の更新はコンポーネントを利用します。
Vue.jsの3ではVue.createAppメソッドで作成されるappインスタンスのcomponentsメソッドを利用してコンポーネントを定義します。
コンポーネント定義後にmountメソッドでid=”app”にマウントを行います。設定の順番を間違えるとエラーになります。
const app = Vue.createApp({
//略
});
app.component(コンポーネント名, {
props:[...],
data(){
return {
....
}
},
methods:{
....
},
template: 'ブラウザに表示させたい内容'
});
app.mount('#app')
コンポーネントの追加
コンポーネントを追加してカテゴリー名を表示させます。
category-name-updateコンポーネントを追加し、propsで親のコンポーネントからcategoryを受け取り、categoryオブジェクトのnameを表示しています。
const app = Vue.createApp({
//略
});
app.component('category-name-update', {
props: ['category'],
template: `
{{ this.category.name }}
`,
});
app.mount('#app');
これまでのカテゴリー名を表示させていた下記のdivタグをcategoryp-name-updateタグに置き換えます。
<div class="font-bold">{{ category.name }}</div>
<category-name-update
:category="category">
</category-name-update>
category-name-updateタグに置き換えても何も変化がないことを確認します。
ブラウザ上では違いはありませんせんが、表示されているカテゴリー名のテストA, テストB, …の部分はcategory-name-updateによりコンポーネント化されています。
input要素の設定
カテゴリー名をクリックすると下記のようにinput要素が表示されるようにコードを更新していきます。
データプロパティshowをcategoryp-name-updateコンポーネントに追加し、v-ifディレクティブとclickイベントで非表示から表示へと切り替えます。
app.component('category-name-update', {
props: ['category'],
data(){
return {
show:false,
}
},
template: `
<div class="font-bold"
v-if="!show"
@click="show = true">
{{ this.category.name }}
</div>
<div v-else><input /></div>
`,
});
設定後カテゴリー名(テストA)をクリックするとinput要素が表示されますが、値が何も設定されていないので空白となります。
input要素にカテゴリー名が表示、更新できるようにデータプロパティcategory_nameを追加し、input要素にv-modelでデータバインディングします。inputに入力した値が{{ category_name }}で表示されるか確認します。
app.component('category-name-update', {
props: ['category'],
data(){
return {
show:false,
category_name:'',
}
},
template: `
<div class="font-bold"
v-if="!show"
@click="show = true">
{{ this.category.name }}
</div>
<div v-else>
<input v-model="category_name"/>{{ category_name }}
</div>
`,
});
下記のようにinput要素の横に入力した値が表示されれば設定は正常におこなわれています。
clickイベントにshowInputメソッドを設定し、propsで受け取ったcategory.nameをデータプロパティのcategory_nameに設定します。
app.component('category-name-update', {
props: ['category'],
data(){
return {
show:false,
category_name:'',
}
},
methods:{
showInput(){
this.category_name = this.category.name
this.show = true;
}
},
template: `
<div class="font-bold"
v-if="!show"
@click="showInput">
{{ category.name }}
</div>
<div v-else>
<input v-model="category_name"/>
</div>
`,
});
category_nameはv-modelによりinput要素とバインディングしているのでカテゴリー名をクリックするとinput要素の中にカテゴリー名が表示されます。
blur, keyupイベントの設定
input要素に入れたカーソルを外した場合かEnterキーを押した場合に変更を反映させるupdateNameメソッドが実行できるようにinput要素にblurイベントとkeyupイベントを設定します。
<input
v-model="category_name"
@blur="updateName"
@keyup.enter="updateName"
/>
動作確認を行うため、updateNameメソッドではinput要素で入力した値を表示させます。
methods:{
//略
updateName(){
console.log(this.category_name)
}
},
input要素の値を変更後、Enterキーまたはinput要素からカーソルを外すとデベロッパーツールのコンソールにカテゴリー名が表示されれば設定は正常に行われています。
$emitの設定
カテゴリー名の変更を親コンポーネントに伝えるため、$emitを利用します。$emitの第一引数には親コンポーネントで受け取る任意のイベント名を設定します。また第二, 第三引数にはカテゴリー名の更新に必要なカテゴリーの名前とIDを設定します。
updateName(){
this.show = false;
this.$emit('category-name-updated', this.category_name, this.category.id)
}
親コンポーネントで子コンポーネントから発火されるcategory-name-updatedイベントを受け取れるように設定を行います。@の後に子コンポーネントのemitで指定した名前を設定します。
<category-name-update
:category="category"
@category-name-updated="categoryNameUpdate"
>
category-name-updatedイベントを受け取るとcategoryNameUpdateメソッドが実行されます。
cagtegoryNameUpdateメソッドでは$emitの引数に指定したカテゴリーのnameとidを利用して、categoriesから受け取ったidを持つカテゴリーを見つけ、名前の更新を行っています。
categoryNameUpdate(category_name, category_id) {
let update_category = this.categories.find(cat => cat.id === category_id)
update_category.name = category_name
},
動作確認を行うとカテゴリー名を変更して名前を変更後、Enterを押すと変更したカテゴリー名が表示されます。
focusの設定
カテゴリー名の更新を行うことができるようになりましたが、クリック後にinput要素にフォーカスしない場合にinput要素が表示されたままになります。
下記ではテストAのカテゴリー名をクリックした後何もせず、テストBをクリックした場合。どちらもinput要素が表示されたままの状態になっています。
クリック後にinput要素に自動でfocusできるようにfocusメソッドを利用します。
Vue.jsからinput要素に直接アクセスできるようにref属性を設定します。inputと設定していますが名前は識別子なので任意です。
<input
v-model="category_name"
@blur="updateName"
@keyup.enter="updateName"
ref="input"
/>
focusInputメソッドを追加します。追加したfocusInputメソッドではref属性で指定したinput要素にアクセスしてfocusメソッドでフォーカスを行っています。focusInputメソッドはshowInpurメソッドの中で実行します。つまりカテゴリー名をクリックするとfocusInputメソッドが実行されます。
methods:{
showInput(){
this.category_name = this.category.name
this.focusInput();
this.show = true;
},
focusInput() {
this.$refs.input.focus();
},
this.focusInputをそのまま設定しただけでは期待通りには動作しないのでVue.nextTickを利用します。
showInput(){
this.category_name = this.category.name
this.show = true;
Vue.nextTick(() => {
this.focusInput();
});
},
nextTick設定後、カテゴリー名をクリックするとinput要素が自動でfocusされます。
ここまでの設定でタスクの更新機能は実装できました。
タスクの追加
タスクの追加はカテゴリーの追加(コンポーネントの処理)とカテゴリーの更新(input要素の処理)で行った処理を活用して行っていきます。新しい設定の処理はありません。
”タスクの追加”は各カテゴリーのタスク一覧の一番下で行います。
コンポーネントの追加
カテゴリーの更新と同様にコンポーネントを追加します。コンポーネントの名前はtask-addとします。
app.component('task-add',{
template:`
<div class="flex mx-2 hover:bg-gray-300 rounded-lg">
<span class="p-2">タスクを追加</span>
</div>
`
})
task-addコンポーネントの追加場所は、tasksのv-forタグの下です。
<div
v-for="(task,index) in category.tasks"
:key="index"
class="m-2 bg-white p-2"
@dragstart="dragTask(task)"
@dragover.prevent="dragOverTask(task)"
draggable=true >
{{ task.name}}
</div>
<task-add></task-add>
追加後はタスク一覧の下に”タスクの追加”の文字列が表示されます。
hoverを設定しているので”タスクの追加”の上にマウスを押すと背景色が変わります。
input要素の表示
タスクを追加をクリックするとカテゴリーの追加と同様にinput要素が表示されるように設定を行います。
app.component('task-add', {
data() {
return {
show: false,
}
},
methods: {
showInput() {
this.show = true;
},
closeInput() {
this.show = false;
},
},
template: `
<div
class="flex mx-2 hover:bg-gray-300 rounded-lg"
v-if="!show"
@click="showInput">
<span class="p-2">タスクを追加</span>
</div>
<div class="mx-2" v-else>
<div>
<input
type="text"
class="w-full p-2"
placeholder="新しいタスク名を入力してください"
/>
</div>
<div class="flex m-2">
<button
class="px-4 py-2 bg-green-500 hover:bg-green-700 text-white rounded-lg mr-2 font-bold text-xs"
>追加
</button>
<button
class="px-4 py-2 bg-red-500 hover:bg-red-700 text-white rounded-lg font-bold text-xs"
@click="closeInput"
>
キャンセル
</button>
</div>
</div>
`
})
”タスクを追加”の文字列をクリックするとinput要素とその下に”追加ボタン”と”キャンセルボタン”が表示されます。”キャンセルボタン”にcloseInputメソッドを設定しているのでボタンを押すとinput要素が消え、再び”タスクを追加”の文字列が表示されます。
input要素に入力した値を保持できるようにtask_nameを追加します。
data() {
return {
show: false,
task_name:'',
}
},
closeInputでinput要素を非表示にした時に入力した値をクリアするためにcloseInputメソッドを更新します。
closeInput(){
this.show=false;
this.task_name = '';
},
input要素に追加したtask_nameをv-modelを使ってデータバインディングを行います。
<input
type="text"
class="w-full p-2"
placeholder="新しいタスク名を入力してください"
v-model="task_name"
/>
タスクを追加する場合はどのカテゴリーに追加するのか知っておく必要があるので、親コンポーネントからpropsでcategoryのIDを渡します。
<task-add :category_id="category.id"></task-add>
propsを受け取れるようにtask-addコンポーネントを更新します。
app.component('task-add', {
props: ['category_id'],
//略
次に”追加ボタン”にclickイベントを追加し、addTaskメソッドを設定します。
<button
class="px-4 py-2 bg-green-500 hover:bg-green-700 text-white rounded-lg mr-2 font-bold text-xs"
@click="addTask"
>追加
</button>
addTaskメソッドではinput要素に値が入力されている場合のみ$emitで親コンポーネントに追加したタスク情報を伝えます。$emitの引数には入力したタスク名とpropsから受け取ったカテゴリーのIDを渡します。
addTask() {
if (this.task_name != '') {
this.$emit('TaskAdded', this.task_name, this.category_id)
this.show = false;
this.task_name = '';
}
}
親コンポーネントでTaskAddedイベントを受け取れるように設定を行い、イベントを受け取るとtaskAddメソッドを実行します。
<task-add @task-added="taskAdd" :category_id="category.id"></task-add>
$emitの引数で設定されたタスク名とカテゴリーのidを受け取りtasks配列にpushメソッドでタスクを追加しています。
taskAdd(task_name, category_id) {
this.tasks.push({
id: Date.now(),
category_id,
name: task_name
})
},
ここまでの設定でタスクの追加が可能となります。
追加されたタスクはDrag&Dropで他のカテゴリーに移動することも同一のカテゴリー内を移動することも可能です。
focusの設定
カテゴリー名の更新と同様の方法でfocusの設定を行います。
input要素にref属性を追加します。
<input
type="text"
class="w-full p-2"
placeholder="新しいタスク名を入力してください"
v-model="task_name"
ref="input"
/>
Vue.nextTickをshowInputメソッドに追加し、focusInputメソッドを実行します。focusInputメソッドではref属性を設定したinput要素にアクセスし、focusメソッドを実行しています。
methods: {
focusInput() {
this.$refs.input.focus();
},
showInput() {
this.show = true;
Vue.nextTick(() => {
this.focusInput();
});
},
focusの設定を行うと”タスクを追加”をクリックするとinput要素が自動でfocusされます。
新規タスクの追加の実装は完了です。
タスクの更新
タスクの更新についてはこれまでの処理とは異なり、Vue.jsのteleportを利用したモーダルウィンドウを作成し、モーダルウィンドウで更新が行えるように設定を行います。
タスク名をクリックするとブラウザの画面の中央にモーダルウィンドウが表示されます。
teleportの設定
タスク名をクリックするとモーダルウィンドウが表示されるようにteleportの設定を行います。
teleportを利用することでHTMLを記述した場所ではなく指定した場所に表示させることが可能となります。本アプリケーションでは更新フォームをHTMLのページ上部に記述しますが、表示はbodyの閉じタグの手前で行います。
bodyタグの閉じタグの直前にdiv要素を追加し、idにmodalを設定します。idに設定する名前は任意で識別子となりteleportタグのto属性でその名前を指定します。
<div id="modal">
</div>
</body>
idがmodalの要素の中に表示させたい内容はteleportタグの中に記述しto属性でmodalを指定します。追加する場所はページ上部のid=”app”を持つdivタグの下です。
<body>
<div id="app">
<teleport to="#modal">
<div>bodyの閉じタグの前に表示</div>
</teleport>
これだけでteleportの設定は完了です。ブラウザで確認するとページの先頭ではなく、bodyの閉じタグの前にteleportタグの中身が表示されます。
モーダルウィンドウの設定
タスク名をクリックするとモーダルウィンドウを表示させるためモーダルウィンドウの表示・非表示を制御するデータプロパティmodalを追加します。動作確認のためデフォルト値はtrueに設定しておきます。
data() {
return {
//略
modal:true,
teleportタグの中にモーダルウィンドウを構成する要素を追加します。classにcontentが設定されているdiv中にブラウザ上で表示させたい内容を記述していきます。
<teleport to="#modal">
<div class="base" v-show="modal">
<div class="overlay" v-show="modal" @click="modal=false">
</div>
<div class="content" v-show="modal">
<div>bodyの閉じタグの前に表示</div>
</div>
</div>
</teleport>
各divにはclassが設定されているのでclassのCSSはstyleタグを追加しその中に設定を行います。
<head>
//略
<title>Trello風タスク管理</title>
<style>
.base {
position: fixed;
top: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
margin-top: 50px;
}
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: gray;
opacity: 0.5;
}
.content {
background-color: white;
position: relative;
border-radius: 10px;
padding: 40px;
}
</style>
</head>
設定完了後ブラウザ上にモーダルウィンドウが表示されます。中心に表示されている背景が白以外の周りの領域(オーバーレイ)をクリックするとモーダルウィンドウは非表示になります。
タスク名をクリックするとモーダルウィンドウが表示されるように設定を行います。
データプロパティmodalの初期値をtrueからfalseに変更します。
data() {
return {
//略
modal:false,
タスク名にclickイベントを設定します。openModalメソッドを設定し、引数にcategoryとtaskを設定します。
<div
v-for="(task,index) in category.tasks"
:key="index"
class="m-2 bg-white p-2"
@dragstart="dragTask(task)"
@dragover.prevent="dragOverTask(task)"
draggable=true
@click="openModal(category,task)" //追加
>
{{ task.name}}
</div>
Vue.jsのmethodsにopenModalメソッドを追加します。
openModal(category, task) {
this.modal = true;
},
設定後、タスク名をクリックするとモーダルウィンドウが表示され、オーバーレイをクリックするとモーダルウィンドウは非表示になります。
フォームの追加
更新フォームのデータを保持するためにデータプロパティformを追加します。formオブジェクトの中にはタスクを構成するプロパティの初期値を設定します。
data(){
return {
//略
form:{
id:'',
category_id:'',
name:'',
start_date:'',
end_date:'',
incharge_user:'',
percentage:''
},
openModalメソッドの中でformオブジェクトにタスクの情報を設定します。設定にはObject.assignを利用しています。categoryも値を設定します。
openModal(category, task) {
this.category = category;
Object.assign(this.form, task);
this.modal = true;
},
teleportタグのclass=”content”のdivタグの中にフォームを追加します。タスク情報のincharge_user(担当者), start_date(開始日), end_date(終了締切日)のみ更新可能なフォームとしています。
<div class="content" v-show="modal">
<div class="text font-bold">{{ form.name }}</div>
<div class="text-xs">in カテゴリー{{ this.category.name }}</div>
<div class="my-4">
<label class="text-xs">
担当者
</label>
<input class="border rounded-lg px-4 py-2 text-xs" v-model="form.incharge_user">
</div>
<div class="my-4">
<label class="text-xs">
開始日
</label>
<input class="border rounded-lg px-4 py-2 text-xs" v-model="form.start_date">
</div>
<div class="my-4">
<label class="text-xs">
終了締切日
</label>
<input class="border rounded-lg px-4 py-2 text-xs" v-model="form.end_date">
</div>
<button class="px-4 py-2 bg-green-500 hover:bg-green-700 text-white rounded-lg mr-2 font-bold text-xs"
@click="taskUpdate">更新
</button>
</div>
設定後、タスク名をクリックするとタスク情報が入った状態で表示されます。
更新ボタンをクリックすると更新内容が反映されるようにtaskUpdateメソッドを追加します。
formのidを利用して更新を行うタスクを見つけ、formに入力した値をObject.assignメソッドを利用して既存のタスクの情報を更新しています。更新後はモーダルウィンドウを閉じるためmodalプロパティをfalseにしています。
taskUpdate() {
let task = this.tasks.find(task => task.id === this.form.id)
Object.assign(task, this.form)
this.modal = false;
}
設定が完了すると更新フォームを利用してタスクの情報を更新することができます。
ここまでの設定で、カテゴリー追加、更新、タスクの追加、更新の機能を実装することができました。その他にもタスク、カテゴリーの機能が必要となりますが難しいことではないので各自チャレンジしてみてください。