はじめに
本記事は以下で投稿した記事の続きです。
https://qiita.com/khata0131/items/b455c2b12eb0ccf41d77
前記事では、テーブルの中にStory(行タイトル)を登録して、その配下にTicket(カンバン)を登録できるWebページを作成しました。
本記事では、前記事で作成したテーブルをもとに、以下の追加を実施していきます。
・コンポーネント化
・Ticketのステータスを追加
・ステータスの移動処理を実装
1.Vueコンポーネントとは
Vueコンポーネントは、名前付きの再利用可能なVueインスタンスです。
参考:https://jp.vuejs.org/v2/guide/components.html
以下のようにmessage-componentというコンポーネントを作成した場合、
Vue.component('message-component', {
template: '<p>Hello World!!!</p>'
})
new Vue({ el: '#components-demo' })
コンポーネントに定義したコードを<message-component>というカスタム要素として、Vueインスタンスが設定されたHTMLから呼び出すことができます。
<div id="components-demo">
<message-component></message-component>
</div>
2.コンポーネントの実装
今回はStory配下に登録されるTicketをコンポーネントとして作成する。(以下、青枠部分)
- チケットが登録されるliタグにis属性を用いてコンポーネントを設定
- コンポーネント側に送りたい値をv-bind属性で指定(今回はticket配列を送る)
- コンポーネント側で受け取りたい値をpropsで定義、v-bind:○○の○○部分と同じ名称とする
- チケット表示部分のコンポーネントを作成する
<tbody>
<tr v-for="(story,index) in table" v-bind:key="index">
<td class="h-column">
<span>{{story.storyTitle}}</span>
<div class="add-ticket-area">
<button v-on:click="openModal(index)" class="add-ticket-btn" />
</div>
</td>
<td>
<li is="ticket-template"
v-for="(ticket,index) in story.row"
v-bind:key="index"
v-bind:ticket="ticket">
</li>
</td>
</tr>
</tbody>
Vue.component('ticket-template', {
props: ['ticket'],
template: '\
<li>\
{{ticket.ticketTitle}}\
</li>\
'
})
現時点では、チケットが持つ情報が簡素のため必要性を感じませんが、以降の章でチケットに別の機能を持たせていきます。
3.ステータスの追加
各チケットの進捗を把握するため、2列目以降にステータスごとの列を作成します。
StoryとTicketを保持していたTable配列の入れ子を増やし、ステータスの情報を持つように変更しております。
table: [{
storyTitle: '',
row: [{ ticketTitle: '' }],
}],
table: [{ //緑枠部分
storyTitle: '',
column: [{ //青枠部分
list: [{ //黄枠部分(ステータス:Open)
ticketTitle: ''}]
},{
list: [{ //黄枠部分(ステータス:Ongoing)
ticketTitle: ''}]
},{
list: [{ //黄枠部分(ステータス:Reviewing)
ticketTitle: ''}]
},{
list: [{ //黄枠部分(ステータス:Close)
ticketTitle: ''}]
}]
}],
2列目のtdタグに対して、新しく作成するticket-col-templateコンポーネントを設定します。
<tbody>
<tr v-for="(story,index) in table" v-bind:key="index">
<td class="h-column">
<span>{{story.storyTitle}}</span>
<div class="add-ticket-area">
<button v-on:click="openModal(index)" class="add-ticket-btn" />
</div>
</td>
<td is="ticket-col-template"
v-for="(status,statusindex) in story.column"
v-bind:key="index"
v-bind:story="story"
v-bind:status="status"
v-bind:statusindex="statusindex">
</td>
</tr>
</tbody>
Vue.component('ticket-col-template', {
props: ['story', 'status', 'statusindex'],
template: '\
<td>\
<li is="ticket-template"\
v-for="(ticket,index) in status.column"\
v-bind:key="index"\
v-bind:story="story"\
v-bind:ticket="ticket"\
v-bind:tindex="index"\
v-bind:statusindex="statusindex"\
class="ticket-li">\
</li>\
</td>\
',
})
v-for属性に設定する値は、trタグのv-for属性に設定されているtableの要素であるstoryの子配列のcolumnを指定します。
v-bind属性については、後続処理で使用する値を設定しております。(後述)
tdタグを動的に生成することになりますが、Story追加時に各ステータスのlistを空の状態で初期設定しているため、チケットが登録されていない状態でもtdタグは4列分生成されるようにしております。
this.table.push({ storyTitle: '', column: [{ list: [] }, { list: [] }, { list: [] }, { list: [] }] });
ticket-col-templateコンポーネントの中では、前章で作成したticket-templateコンポーネントを改修して、各ステータスのチケットを格納するliタグを作成していきます。
v-for属性に設定する値は、tdタグのv-for属性に設定されているstory.columnの要素であるstatusの子配列のlistを指定します。
v-bind属性については、後続処理で使用する値を設定しております。(後述)
これで、各ステータスのチケットを表示する枠組みの設定は完了しました。
※チケット新規登録時にステータスOpen(story.status[0].list)に登録されるよう変更しております。
次に、各チケットにステータス移動のボタンを追加していきます。
Vue.component('ticket-template', {
props: ['story', 'ticket', 'tindex', 'statusindex'],
template: '\
<li>\
<div class="ticket-area">\
<span>{{ticket.ticketTitle}}</span>\
<div class="status-btn-area">\
<button v-show="statusindex != 0" v-on:click="statusChange(story, tindex, statusindex, false)" class="statusup-btn" />\
<button v-show="statusindex != 3" v-on:click="statusChange(story, tindex, statusindex, true)" class="statusdown-btn" />\
</div>\
</div>\
</li>\
',
methods: {
statusChange: function (story, tindex, statusindex, isUp) {
this.dummyTicket = story.column[statusindex].list[tindex];
story.column[statusindex].list.splice(tindex, 1);
if(isUp){
story.column[statusindex + 1].list.push({ ticketTitle: this.dummyTicket.ticketTitle });
}
else{
story.column[statusindex - 1].list.push({ ticketTitle: this.dummyTicket.ticketTitle });
}
}
}
})
ステータスアップ用のボタンと、ステータスダウン用のボタンを作成しました。
v-show属性にて、ステータスがOpenの場合はステータスダウンボタンを非表示、ステータスがCloseの場合はステータスアップボタンを非表示にしています。
v-on:click属性にて、ticket-templateに定義したstatusChangeメソッドを設定しています。引数に設定する値として、HTML側からv-bindで継承してきた値を設定しています。
配列間の値を移動させた後の配列の状態で各v-forが動作し、HTMLが描画されております。
これにより、動的な各ステータスのチケットの表示を実現しております。
4.親コンポーネントのメソッドを実施する場合
「3.ステータスの追加」では以下のようにステータスチェンジのメソッドを実行していました。
親コンポーネント(メインのVueインスタンス)
↓ ステータスチェンジのメソッドに必要な値を送る
子コンポーネント(Statasコンポーネント)
↓ ステータスチェンジのメソッドに必要な値を送る
孫コンポーネント(Ticketコンポーネント)
→ステータスチェンジのメソッドを実行
上記とは逆で、親コンポーネントでメソッドを実行して、孫コンポーネントから値を送るように実装することも可能です。
その場合の方法を以下に記します。(最終的なコードと異なりますがご了承ください。)
<li is="story-template"
v-for="(story,index) in table"
v-bind:story="story"
v-bind:sindex="index"
v-bind:open="story.column[0].list"
~省略~
@statusup="statusUp"
@statusdown="statusDown">
</li>
new Vue({
el: '#app',
methods: {
statusUp: function (pindex, tindex, status) {
this.dummyTicket = this.table[pindex].state[status].column[tindex];
this.table[pindex].state[status].column.splice(tindex, 1);
this.table[pindex].state[status + 1].column.push({ parentIndex: pindex, ticketTitle: this.dummyTicket.ticketTitle });
},
statusDown: function (pindex, tindex, status) {
this.dummyTicket = this.table[pindex].state[status].column[tindex];
this.table[pindex].state[status].column.splice(tindex, 1);
this.table[pindex].state[status - 1].column.push({ parentIndex: pindex, ticketTitle: this.dummyTicket.ticketTitle });
},
},
})
Vue.component('story-template', {
props: ['story', 'sindex', 'open'],
template: '\
<li>\
<span>{{story.storyTitle}}</span>\
<div class="add-ticket-area">\
<button v-on:click="$emit(\'add-ticket\')" class="add-ticket-btn" />\
</div>\
<div class=ticket-position>\
<ul class="ticket-column">\
<li\
is="ticket-template"\
v-for="(ticket,index) in open"\
v-bind:ticket="ticket"\
v-bind:tindex="index"\
v-bind:status=0\
v-bind:key="index"\
class="open"\
@statusup=statusUp\
>\
</li>\
</ul>\
</li>\
',
methods: {
statusUp: function (pindex, tindex, status) {
this.$emit('statusup', pindex, tindex, status)
},
statusDown: function (pindex, tindex, status) {
this.$emit('statusdown', pindex, tindex, status)
}
}
})
Vue.component('ticket-template', {
props: ['ticket', 'tindex', 'status'],
template: '\
<li>\
<div class="ticket-area">\
<span>{{ticket.ticketTitle}}</span>\
<div class="status-btn-area">\
<button v-show="status != 0" v-on:click="$emit(\'statusdown\', ticket.parentIndex, tindex, status)" class="statusup-btn" />\
<button v-show="status != 3" v-on:click="$emit(\'statusup\', ticket.parentIndex, tindex, status)" class="statusdown-btn" />\
</div>\
</div>\
</li>\
'
})
孫で親のstatusUpメソッドを実行するために、$emitでイベント名と値を送ることができます。
直接孫から親へ送ることはできないので、子で孫が指定したイベントを定義しておきます。
今回はイベント"statusup"を指定しているため、子では@statusup=statusUpと定義しておきます。
これにより、子のstatusUpメソッドの実行ができたため、子から親へ値を送る処理をstatusUpメソッドに実装します。(孫で$emitしたことと同じ)
$emitで送った値については、自動的に実行したメソッドの引数として送られます。
親でも子と同様に受取先を定義して置くことで、親のメソッドを実行することができます。
私が今回作成したWebページでも最初はこの方法をとっておりましたが、今回は孫コンポーネントでメソッドを実行したほうが共通化に適していたため修正しました。場合によっては、親コンポーネントでメソッドを実行する必要があるケースもあると思いますので、状況に応じて使い分けるのが良いと思います。
memo:Style調整時に気づいたポイント
border-collapse:collapse または separate
tableタグの隣接するセルのボーダーを重ねて表示するか(collapse)、間隔をあけて表示するか(separate)を指定できます。
各セル間の隙間を調整するためのborder-spacingプロパティが存在しますが、border-collapseプロパティでseparateが設定されている必要があります。
また、trタグで横一直線につながった枠線を引く場合は、border-collapseプロパティでcollapseが設定されている必要があります。
border-collapseプロパティに設定する値によってできることが違うため、勉強が必要だと感じました。
擬似要素::after
擬似要素::afterを設定したが適用されないケースがありました。
擬似要素::afterを使用する場合はcontentプロパティが必須となっています。
buttonタグなどで"+"などを設定しているときは気が付きませんでしたが、テキストを設定する必要がない場合でも""(ブランク)を設定する必要があるようです。
終わりに
Vueの特徴でもあるコンポーネントについて学びましたが、動的なHTMLを形成しながらもシンプルな作りで開発をすることができ便利さを実感することができた。ただ、コンポーネントに親子関係ができた際の継承方法などに理解に時間がかかった。最終的にはシンプルな形に落とし込むことができたが、途中まではコンポーネントを作成してはいるもののコンポーネントの情報の持ち方が複雑になってしまっていた。それを自分の理解が進むにつれ、共通化できる部分をまとめていくことができた。コンポーネントを使うことで、ただ実装するだけではなくコードの読みやすさや編集のしやすさなども気にしながらコーディングすることができていたので、今後もその意識をもって取り組んでいきたいと思いました。
コード全文
<!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>Story</th>
<th>Open</th>
<th>Ongoing</th>
<th>Reviewing</th>
<th>Close</th>
</tr>
</thead>
<tbody>
<tr v-for="(story,index) in table" v-bind:key="index">
<td class="h-column">
<span>{{story.storyTitle}}</span>
<div class="add-ticket-area">
<button v-on:click="openModal(index)" class="add-ticket-btn" />
</div>
</td>
<td is="ticket-col-template" v-for="(status,statusindex) in story.column" v-bind:key="index"
v-bind:story="story" v-bind:status="status" v-bind:statusindex="statusindex">
</td>
</tr>
</tbody>
</table>
</div>
</main>
<footer>
<div class="copyright">Copyright</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<script src="../js/main.js"></script>
</body>
</html>
Vue.component('ticket-col-template', {
props: ['story', 'status', 'statusindex'],
template: '\
<td>\
<li is="ticket-template"\
v-for="(ticket,index) in status.list"\
v-bind:key="index"\
v-bind:story="story"\
v-bind:ticket="ticket"\
v-bind:tindex="index"\
v-bind:statusindex="statusindex"\
class="ticket-li">\
</li>\
</td>\
',
})
Vue.component('ticket-template', {
props: ['story', 'ticket', 'tindex', 'statusindex'],
template: '\
<li>\
<div class="ticket-area">\
<span>{{ticket.ticketTitle}}</span>\
<div class="status-btn-area">\
<button v-show="statusindex != 0" v-on:click="statusChange(story, tindex, statusindex, false)" class="statusup-btn" />\
<button v-show="statusindex != 3" v-on:click="statusChange(story, tindex, statusindex, true)" class="statusdown-btn" />\
</div>\
</div>\
</li>\
',
methods: {
statusChange: function (story, tindex, statusindex, isUp) {
this.dummyTicket = story.column[statusindex].list[tindex];
story.column[statusindex].list.splice(tindex, 1);
if (isUp) {
story.column[statusindex + 1].list.push({ ticketTitle: this.dummyTicket.ticketTitle });
}
else {
story.column[statusindex - 1].list.push({ ticketTitle: this.dummyTicket.ticketTitle });
}
}
}
})
new Vue({
el: '#app',
data: {
showModal: false,
selectIndex: '',
newStory: '',
newTicket: '',
table: [{
storyTitle: '',
column: [
{ list: [{ ticketTitle: '' }] },
{ list: [{ ticketTitle: '' }] },
{ list: [{ ticketTitle: '' }] },
{ list: [{ ticketTitle: '' }] }
]
}],
dummyTicket: [{
ticketTitle: '',
}]
},
mounted: function (event) {
this.table.splice(0, 1);
},
methods: {
addStory: function () {
this.table.push({ storyTitle: '', column: [{ list: [] }, { list: [] }, { list: [] }, { list: [] }] });
this.table[this.table.length - 1].storyTitle = this.newStory;
this.newStory = "";
},
addTicket: function () {
this.table[this.selectIndex].column[0].list.push({ ticketTitle: '' });
this.table[this.selectIndex].column[0].list[this.table[this.selectIndex].column[0].list.length - 1].ticketTitle = this.newTicket;
this.closeModal();
},
openModal: function (index) {
this.showModal = true;
this.selectIndex = index;
},
closeModal: function (event) {
this.showModal = false;
this.selectIndex = '';
this.newTicket = "";
},
},
})