はじめに
当記事は自己研鑽の一環として、Vue.jsにてカンバン形式のタスク管理ツールを作成するまでの過程を備忘としてまとめていくものになります。自分用の記事で拙い文章となっていることをご容赦ください。
学習の経緯
モチベーション
今までもフロントエンドコーディングの自己研鑽をしようと試みてきましたが、中々続きませんでした。理由としては、本などからのインプットはできていたのですが、それをアウトプットする機会を作れていなかったことです。元々自分がクリエイター寄りの思考のため、自分でアイディアを出して何かものを作りたいという思いから、仕様から考えてWebサイトの構築をしようとしてきましたが、その検討の段階で心が折れて続かないということがほとんどでした。
そのため、今回はコーディング力の向上のためと割り切って、既存サイトを再現してみようと初めてみました。この方法は思いのほか自分に合っていたようで、継続して続けることができています。
Vue.js
学ぶ題材として、今回はVue.jsを選択しました。理由として、Microsoft主催のde:codeのセミナーでセッションを聞いて関心が深まっており、ドットインストールでもVue.jsについての授業を視聴させていただいたときに、一度勉強してみたいと思いました。
また、機能の中にHTMLをパーツとして定義できるテンプレートがあり、後述するカンバン形式のタスク管理ツールを作成する際に活用できそうと考え、実際に実装してみたいと思いました。
作業目標
カンバン形式のタスク管理ツールであるRedmineというオープンソースソフトウェアを完成イメージとして、Vue.jsを用いてコーディングを進めていきます。
参考* Redmine.JP公式:https://redmine.jp/overview/
参考* カンバンイメージ:https://www.atmarkit.co.jp/ait/articles/1112/26/news119.html
成果
以下、実装した機能です。
・ストーリーの追加
・チケットの追加
作成したコードについて、本記事の末に全文記載しておきます。
実装機能:ストーリーの追加
ストーリー列の表示をv-forディレクティブを使用して実装する。
<tr v-for="(story,index) in table">
<td class="h-column">
<div class="story-area">
<div class="story-title">{{ story.storyTitle }}</div>
<div class="add-ticket-btn">
<button v-on:click="openModal(index)" class="btn-circle-border-simple">+</button>
</div>
</div>
</td>
<td>
<li v-for="(ticket,index) in story.row">{{ticket.ticketTitle}}</li>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tbody>
<tr>
<td class="h-column">
<div class="story-area">
<div class="story-title">案件A 単体テスト実施</div>
<div class="add-ticket-btn">
<button class="btn-circle-border-simple">+</button>
</div>
</div>
</td>
<td>
<li>Topページ</li>
<li>問い合わせページ</li>
<li>会社概要ページ</li>
<li>PrivacyPolicyページ</li>
</td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td class="h-column">
<div class="story-area">
<div class="story-title">案件A 結合テスト実施</div>
<div class="add-ticket-btn">
<button class="btn-circle-border-simple">+</button>
</div>
</div>
</td>
<td>
<li>問い合わせの登録</li>
</td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td class="h-column">
<div class="story-area">
<div class="story-title">案件B 要件定義</div>
<div class="add-ticket-btn"><button class="btn-circle-border-simple">+</button></div>
</div>
</td>
<td>
<li>ユーザへの資料送付</li>
<li>見積作成</li>
</td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody>
<tr>タグにtable配列のv-forディレクティブを設定することにより、table配列の中に要素を追加することで<tr>タグで囲われたHTMLを反復して生成することができる。画面イメージを例にすると、ストーリーは3つ登録されているため、trタグは3回反復して生成されることになる。
<div class="entory-story">
<input class="story-text" v-model:value="newStory"><br>
<button v-on:click="addStory()">Add new story</button>
</div>
ストーリーの追加処理は、表の上のテキストボックスにストーリー名を入力して、「Add new story」ボタンを押すことで入力されたストーリー名の行が登録される。テキストボックスにv-modelディレクティブを設定することで、JavaScript側のdataで宣言している変数(今回は"newStory")に対して値を送ることができる。ボタンにv-onディレクティブを設定することで、JavaScript側のmethodで宣言しているメソッド(今回は"addStory()")を処理できる。
data: {
showContent: false,
selectIndex: '',
newStory: '',
newTicket: '',
table: [{
storyTitle: '',
row: [
{
ticketTitle: '',
}
],
}],
},
addStory: function () {
this.table.push({ storyTitle: '', row: [] });
this.table[this.table.length - 1].storyTitle = this.newStory;
this.newStory = "";
},
addStoryメソッドでは、ストーリー名を保持しているtable配列に対して、入力されたストーリー名のデータをプッシュすることで末尾に行を追加している。table配列の構成についてはdataにて宣言されているが、push()の中には配列の構成情報も含める(本記事の「躓いた点」にて後述)。table配列内の変数であるstoryTitleに、HTML側で入力されたストーリー名newStoryを設定する。
v-forに設定している配列内の変数に登録されている値は、HTML側で{{ [配列要素名].[変数] }}のように記載することで表示可能。
実装機能:チケットの追加
<tr v-for="(story,index) in table">
<td class="h-column">
<div class="story-area">
<div class="story-title">{{ story.storyTitle }}</div>
<div class="add-ticket-btn">
<button v-on:click="openModal(index)" class="btn-circle-border-simple">+</button>
</div>
</div>
</td>
<td>
<li v-for="(ticket,index) in story.row">{{ticket.ticketTitle}}</li>
</td>
チケットの追加は、各ストーリー単位で実施できるようにするため、各行にボタンを配置している。押されたボタンの行のインデックスを保持しておく必要があるが、「v-for="(story,index) in table"」 のように記述することで、table配列の要素であるstoryと要素番号であるindexの両方を保持することができる。
<div id="overlay" v-show="showModal" v-on:click="closeModal()"></div>
<div id="modal-window" v-show="showModal">
<input class="ticket-text" v-model:value="newTicket"><br>
<button v-on:click="addTicket()">Add new ticket</button>
<button v-on:click="closeModal()">Close</button>
</div>
openModal: function (index) {
this.showModal= true;
selectIndex = index;
},
closeModal: function (event) {
this.showModal= false;
this.selectIndex = '';
this.newTicket = "";
},
チケット名の入力フォームはモーダルウィンドウにて実装する。+ボタンを押すことで、v-showディレクトリのshowModalがtrueとなりモーダルウィンドウが表示される。また、閉じるボタンかオーバーレイを押す場合は、showModalがfalseとなりモーダルウィンドウは閉じられる。
押された行の要素番号をopenModalメソッドの引数として、後の処理で使うためselectIndexに値を保持しておく。
addTicket: function () {
this.table[selectIndex].row.push({ ticketTitle: '' });
this.table[selectIndex].row[this.table[selectIndex].row.length - 1].ticketTitle = this.newTicket;
this.closeModal();
},
ストーリーの追加と同様に、入力されたチケット名を登録していく。チケット表示の配列は、ストーリー配列tableの子アイテムとして作成しているため、要素番号に押された行の要素番号selectIndexを指定し、ストーリーと同様にプッシュしていく。
躓いた点
1.ストーリー追加処理をformのテキストボックス・ボタンにて実装しようとしたがうまくいかなかった
ストーリーの登録箇所を<form>タグにて実装したが、table配列に新しいストーリーが追加された直後にリロードしてしまい、追加前の状態に戻ってしまっていた。
<form @submit="addStory()">
<input type="text" v-model="newStory">
<input type="submit" value="Add new story">
</form>
調査したところ、Vue.jsで<form>で@clickイベントを実装する場合、必ずリロードされてしまう仕様らしい。
<form>タグから<div>タグに変更することで、リロードを防ぐことができた。
参考:https://fuqda.hatenablog.com/entry/2019/03/26/215727
<div class="entory-story">
<input class="story-text" v-model:value="newStory"><br>
<button v-on:click="addStory()">Add new story</button>
</div>
2.配列のpushでは配列内に定義した変数の構成まではコピーされない
table: [{
storyTitle: '',
row: [
{
ticketTitle: '',
}
],
}],
main.jsのdata内にv-forディレクティブに設定する配列として、上記を宣言している。このtable配列に対してpush()を行うと、table配列の中身は空となる。そのため、データの構成も併せてコピーするためには以下とする必要がある。
this.table.push({ storyTitle: '', row: [] });
* ticketTitleについては、チケット追加時にtable.rowに対してpush()を行うため、その際に定義する。
3.モーダルウィンドウのcloseModalメソッドがz-indexを無視してしまう
モーダルウィンドウの実装について、最初は以下「修正前」のようにoverlayのdivタグで全体を囲う構成にしており、modal-windowのdivタグのz-indexをoverlayより高い値を設定していた。しかし、この構成ではテキストボックスやチケット追加ボタンを押下した時にもoverlayのcloseModalメソッドが処理されてしまい、モーダルウィンドウが閉じてしまう状態になっていた。
<div id="overlay" v-show="showModal" v-on:click="closeModal()">
<div id="modal-window" v-show="showModal">
<input class="ticket-text" v-model:value="newTicket"><br>
<button v-on:click="addTicket()">Add new ticket</button>
<button v-on:click="closeModal()">Close</button>
</div>
</div>
調査したところ、z-indexで画面上の表示順を入れ替えていたとしても、子要素がクリックされたというイベントは親要素まで伝搬されてしまうらしい。
参考:https://qiita.com/mejileben/items/15e5cb3e4649cb5241cb
そのため、modal-windowをoverlayの子要素にするのではなく、別の要素として管理することで独立した処理が行われるように修正した。
<div id="overlay" v-show="showModal" v-on:click="closeModal()"></div>
<div id="modal-window" v-show="showModal">
<input class="ticket-text" v-model:value="newTicket"><br>
<button v-on:click="addTicket()">Add new ticket</button>
<button v-on:click="closeModal()">Close</button>
</div>
終わりに
Vue.jsを初めて触ってみて、躓くことも多く進捗が全くない日などもありましたが、なんとかきりが良いところまで進められてよかったです。特に、v-forを2重配列で制御して表を作成するところが手ごわかったです。ただ、処理の内容が理解できると、簡単なコーディングでSPAを構成することができてとても楽しかったです。今後も継続して進められればと思います。
来年度の上期では、以下を対応予定です。
・テンプレート機能を利用してチケットを詳細化
・チケットを別ステータスへ移動
・スタイルの調整
コード全文
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>My Vue App</title>
<link rel="stylesheet" href="../css/styles.css">
</head>
<body>
<header>
<div class="logo">Project Management</div>
</header>
<nav>
<ul>
<li>プロジェクト</li>
<li>チケット</li>
<li>作業時間</li>
</ul>
</nav>
<main>
<div id="app">
<div id="overlay" v-show="showModal" v-on:click="closeModal()"></div>
<div id="modal-window" v-show="showModal">
<input class="ticket-text" v-model:value="newTicket"><br>
<button v-on:click="addTicket()">Add new ticket</button>
<button v-on:click="closeModal()">Close</button>
</div>
<h1>カンバン</h1>
<div class="entory-story">
<input class="story-text" v-model:value="newStory"><br>
<button v-on:click="addStory()">Add new story</button>
</div>
<table>
<thead>
<tr>
<th>
ストーリー
</th>
<th>
新規
</th>
<th>
進行中
</th>
<th>
レビュー
</th>
<th>
完了
</th>
</tr>
</thead>
<tbody>
<tr v-for="(story,index) in table">
<td class="h-column">
<div class="story-area">
<div class="story-title">{{ story.storyTitle }}</div>
<div class="add-ticket-btn">
<button v-on:click="openModal(index)" class="btn-circle-border-simple">+</button>
</div>
</div>
</td>
<td>
<li v-for="(ticket,index) in story.row">{{ticket.ticketTitle}}</li>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
<tr class="bottom-row">
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
<td>
</td>
</tr>
</tbody>
</table>
</div>
</main>
<footer>
<div class="copyright">2021 Copyright</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<script src="../js/main.js"></script>
</body>
</html>
Vue.component('row-templete', {
template: '<p>aaaaa</p>'
})
var app = new Vue({
el: '#app',
data: {
showModal: false,
selectIndex: '',
newStory: '',
newTicket: '',
table: [{
storyTitle: '',
row: [
{
ticketTitle: '',
}
],
}],
},
mounted: function (event) {
this.table.splice(0, 1);
},
methods: {
addStory: function () {
this.table.push({ storyTitle: '', row: [] });
this.table[this.table.length - 1].storyTitle = this.newStory;
this.newStory = "";
},
addTicket: function () {
this.table[selectIndex].row.push({ ticketTitle: '' });
this.table[selectIndex].row[this.table[selectIndex].row.length - 1].ticketTitle = this.newTicket;
this.closeModal();
},
openModal: function (index) {
this.showModal= true;
selectIndex = index;
},
closeModal: function (event) {
this.showModal= false;
this.selectIndex = '';
this.newTicket = "";
},
}
})
body {
font-family: 'メイリオ', 'Meiryo', sans-serif;
font-size : 14px;
}
header {
background-color: rgb(255, 144, 144);
}
nav {
background-color: rgb(255, 188, 188);
}
nav ul {
text-align: left;
padding : 0px;
font-size : 0;
/* タグ改行時の空白防止 */
}
nav ul li {
display : inline-block;
margin-right: 20px;
font-size : 14px;
/* ulでサイズ0にしたのを戻す */
}
main {
padding-bottom: 10px;
}
footer {
background-color: rgb(255, 144, 144);
margin-top:50px;
}
.logo {
font-size : 36px;
font-style: bold;
}
h1 {
font-size: 28px;
color : brown;
}
table {
border-collapse: collapse;
}
table th,
table td {
border: solid 1px black;
width : 200px;
}
table th {
background-color: rgb(255, 144, 144);
}
.h-column {
background-color: rgb(255, 217, 217);
vertical-align: top;
}
.bottom-row{
height: 15px;
}
.story-area{
display: inline-flex;
width:100%;
}
.entory-story{
padding:20px 0px;
display: inline-flex;
}
.story-text{
width:300px;
}
.story-title{
width:80%;
word-break : break-all;
padding:5px;
}
.add-ticket-btn{
width:20%;
text-align: center;
padding:5px 0px;
}
.ticket-text{
width:300px;
}
tr{
height:auto;
}
td li {
list-style: none; /* デフォルトのアイコンを消す */
margin: 0; /* デフォルト指定上書き */
padding: 0; /* デフォルト指定上書き */
}
td li:before {
content: ""; /* 空の要素作成 */
width: 10px; /* 幅指定 */
height: 10px; /* 高さ指定 */
display: inline-block; /* インラインブロックにする */
background-color: #F44336; /* 背景色指定 */
border-radius: 50%; /* 要素を丸くする */
position: relative; /* 位置調整 */
top: -1px; /* 位置調整 */
margin-right: 5px; /* 余白指定 */
}
/*https://saruwakakun.com/html-css/reference/buttons*/
.btn-circle-border-simple {
display : inline-block;
text-decoration: none;
color : #d86666;
width : 30px;
height : 30px;
border-radius : 50%;
border : solid 2px #d86666;
text-align : center;
overflow : hidden;
font-weight : bold;
transition : .4s;
}
.btn-circle-border-simple:hover {
background: #ffb3b3;
}
/*https://reffect.co.jp/vue/understand-component-by-moda-window*/
# overlay {
/* 要素を重ねた時の順番 */
z-index: 1;
/* 画面全体を覆う設定 */
position : fixed;
top : 0;
left : 0;
width : 100%;
height : 100%;
background-color: rgba(0, 0, 0, 0.5);
}
# modal-window {
z-index : 10;
position : fixed;
width : 50%;
padding : 1em;
background: #fff;
/* 画面の中央に要素を表示させる設定 */
display : flex;
align-items : center;
justify-content: center;
}