vue.jsを使ってテーブルのセル更新

vue.jsを使ってシングルページアプリケーションを作るため、HTMLのtable要素に表示されたセルの更新を別ページに移動することなく更新したいものです。
入門者の方にもわかるようにvue.jsを利用したシンプルな方法で更新可能はセルの設定方法を説明しています。セルを更新するvue.js側のファイルはcdnを使用するため特別な環境は必要がありませんが、テーブルデータを取得するために外部リソースを準備する必要があります。
目次
バックエンドサーバについて
vue.jsを使ってテーブルを描写する際、通常はテーブルのデータはデータベースや外部サービス上に保存されているため外部リソースからデータを取得する必要があります。
本文書では外部リソースであるバックエンドサーバにLaravelを利用しています。vue.jsからのデータ取得/更新/削除処理を受けつけることができればバックエンドがLaravelである必要はありません。Laravelを利用していない/利用しない場合は、この章をスキップして次の章に進んでください。

テーブルについて
Laravel上にusersテーブルを作成します。usersテーブルはユーザ名とEmailの2つの列で構成されています。usersテーブルにはユーザ情報が入っています。
ルーティングについて
vue.jsからaxiosを使用してユーザ情報の取得、更新、削除を行います。そのためにapi.webファイルに以下のルーティングを追加しています。
Route::get('users',function(){
return App\User::all();
});
Route::delete('users/{id}',function($id){
$user = App\User::find($id);
$user->delete();
return response()->json([
'success' => 'user deleted successfully!'
]);
});
Route::patch('users/{id}',function($id,Request $request){
$user = App\User::find($id);
$user->update($request->all());
return response()->json([
'success' => 'user updated successfully!'
],200);
});
CORSの設定について
今回はLaravelの外部にファイルを作成し、そのファイルからaxiosを使ってLaravelにリクエストを送ります。そのため、CORS(Cross Origin Resource Sharing)の設定を行なっていないと以下のエラーが表示され、外部のファイルからLaravelにアクセスすることができません。
Access to XMLHttpRequest at ‘http://127.0.0.1:8000/api/users’ from origin ‘http://localhost:8080’ has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource.
ミドルウェアのCorsを作成します。
$ php artisan make:middleware Cors
Middleware created successfully.
実行するとapp¥Http¥MiddlwareにCors.phpファイルが作成されます。$next($request)に3つの->header()を追加してください。
namespace App\Http\Middleware;
use Closure;
class Cors
{
public function handle($request, Closure $next)
{
return $next($request)
->header('Access-Control-Allow-Origin','*')
->header('Access-Control-Allow-Headers','*')
->header('Access-Control-Allow-Methods','*');
}
}
app¥Http¥Kernel.phpに作成したCorsミドルウェアを追加します。
protected $middleware = [
\App\Http\Middleware\TrustProxies::class,
\App\Http\Middleware\CheckForMaintenanceMode::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
\App\Http\Middleware\Cors::class, //追加
];
ここまでの準備が整ったら、php artisan serverでLaravelの開発サーバを起動します。
$ php artisan serve
Laravel development server started: <http://127.0.0.1:8000>

index.htmlファイルの作成
利用するvue.js、axios、bootstrapはすべてcdnを経由して読み込みます。
scriptタグに記述するコード
HTMLに表示させるテーブルの内容はusersテーブルに保存されたデータを利用して行います。usersテーブルは、ユーザ名とEmailの2つの列で構成されたシンプルな構造をしています。
テーブルのデータはaxiosを通してvue.jsのライフサイクルフックのmountedで取得します。UserTableという名前のコンポーネントを利用して、v-forでtr要素を作成します。

index.htmlファイルを作成して、scriptタグに以下を記述します。コンポーネントuser-tableを追加しています。
<script>
Vue.component('user-table',{
template : `<tr><td>{{ user.id }}</td>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td><button class="btn btn-danger">削除</button></td>
</tr>`,
props : ['user','index']
});
var app = new Vue({
el: '#app',
data: {
users: []
},
mounted: function(){
axios.get('http://127.0.0.1:8000/api/users').then(response => this.users = response.data);
}
})
</script>
HTML側に記述するコード
HTMLは下記のように記述します。scriptタグでvue.jsとaxios、linkタグでbootstrapを読み込みます。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Vue.js</title>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
</head>
<body>
<div id="app">
<table class="table table-bordered">
<thead class="thead-dark">
<tr>
<th>ID</th>
<th>ユーザ名</th>
<th>E-mail</th>
<th>削除</th>
</tr>
</thead>
<tbody>
<tr is="user-table" v-for="(user,index) in users" v-bind:user="user" v-bind:key="user.id" v-bind:index="index"></tr>
</tbody>
</table>
</div>
ここまでの記述では特別な処理は行わずv-forを使ってaxiosから取得したusersデータを展開して、テーブルとして表示しているだけです。
ブラウザで確認すると下記のように表示されます。削除ボタンが付いていますが、何も設定されていないのでクリックしても変化はありません。

コンポーネントの記述について
HTML側ではコンポーネントを以下のように記述しています。
<tr is="user-table" v-for="(user,index) in users" v-bind:user="user" v-bind:key="user.id" v-bind:index="index"></tr>

コンポーネントは<user-table></user-table>タグで記述すると表示が正常に行われないので注意してください。
<user-table v-for="(user,index) in users" v-bind:user="user" v-bind:key="user.id" v-bind:index="index"></user-table>
下記のように崩れたテーブルとして描写されます。

テーブルの列の削除
子コンポーネントuser-tableの設定
最初に削除の機能を追加します。
user-tableコンポーネントのbutton要素にv-on:clickイベントと追加し、クリックを行うと行の番号であるindexとuserのidをdeleteRowメソッドに渡します。
template : `<tr><td>{{ user.id }}</td>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td><button class="btn btn-danger" v-on:click="deleteRow(user.id,index)">削除</button></td>
</tr>`,
deleteRowメソッド内ではaxiosによりその行のdelete処理を行い、$emitメソッドを使って親要素に行が削除されたことを伝えます。
methods: {
deleteRow : function(id,index){
axios.delete('http://127.0.0.1:8000/api/users/' + id)
.then((response) => {
this.$emit('from-child',index)
}).catch((error) =>{
console.log(error)
})
},
},
親側のdelete処理
親側ではUserTableコンポーネントから送られてくるイベントfrom-childを受け取ってdeleteRowを実行します。このdeleteRowメソッドにより、描写されているテーブル上の削除ボタンを押された行が画面上から削除されます。

<tr is="user-table" v-for="(user,index) in users" v-bind:user="user" v-bind:key="user.id" v-bind:index="index" v-on:from-child="deleteRow"></tr>
親側のdeleteRowで行の削除を行います。削除機能の作業は完了です。
methods: {
deleteRow : function(index){
this.$delete(this.users,index)
}
},

テーブルのtd要素の更新
ここからの処理はuser-tableコンポーネントの中身を更新することで行います。
tdをクリックするとinput要素に変える
td要素をダブルクリックするとinput要素に変更する機能を追加します。
input要素の表示・非表示はv-ifディレクティブを使用します。v-ifディレクティブを切り替えるための値として、ユーザ名用にisEditName、email用にisEditEmailというdataを追加し、初期値はfalseとします。
data: function(){
return {
isEditName : false,
isEditEmail : false
}
}
v-ifとisEditNameを組み合わせるとisEditNameがfalseの場合は通常の表示、isEditNameがtrueの場合は、input要素が表示されるようにします。
<td v-if="!isEditName">{{ user.name }}</td>
<td v-else><input type="text" class="form-control" v-model="user.name"></td>
v-ifによってinput要素の非表示・表示を行うためには、isEditNameをfalseからtrueに切り替える仕組みを入れる必要があります。そのために利用するのがdoubleclickイベントです。文字列が入ったtd要素をダブルクリックするとisEditNameがfalseからtrueになりinput要素が表示されます。
<td v-if="!isEditName" v-on:dblclick="isEditName = true">{{ user.name }}</td>
<td v-else><input type="text" class="form-control" v-model="user.name"></td>
先頭のユーザ名をダブルクリックするとinput要素に変わります。

input要素を元の状態に戻す
td要素をダブルクリックをするとinput要素に変わりました。input要素の中身を更新した後にはinput要素から元の状態に戻す必要があります。元の状態に戻すためblurイベントを利用します。input要素にblurイベントを入れることでダブルクリックでinput要素にしたあと、一度input要素にカーソルを合わせて外すとisEditNameがfalseになります。isEditNameがfalseになるのでv-ifディレクティブによりinput要素から元の状態に戻ります。
<td v-if="!isEditName" v-on:dblclick="isEditName = true">{{ user.name }}</td>
<td v-else><input type="text" class="form-control" v-model="user.name" v-on:blur="isEditName=false" ></td>
input要素の更新を反映させる
input要素の切り替えができるようになったので、最後は更新した内容をデータベースに反映させるための処理が必要になります。先程設定したblurイベントを利用しますが、今回は新規のメソッドを追加します。
メソッドの名前はupdateNameとして、変更を行ったuser.idとuser.nameを渡します。
<td v-if="!isEditName" v-on:dblclick="isEditName = true">{{ user.name }}</td>
<td v-else><input type="text" class="form-control" v-model="user.name" v-on:blur="updateName(user.id, user.name)" ></td>
axiosでサーバに対して変更のpatchリクエストを送ります。リクエストが成功した時のみisEditNameの値をfalseにします。

updateName : function(id,name){
axios.patch('http://127.0.0.1:8000/api/users/' + id, {id : id, name: name})
.then((response) => {
this.isEditName = false
}).catch((error) =>{
console.log(error)
})
},
通常ではサーバ側との通信の障害やバグまたValidationによってサーバ側での更新が行われない場合も考えられます。成功した場合には成功メッセージ、失敗した場合にはエラーメッセージを表示する機能をつける必要があります。
実際に更新を行って反映されるか確認しましょう。ユーザIDの1のユーザの名前を山岸から山本に変更します。変更後input要素からカーソルを外すとサーバへの更新が行われます。

axiosを使って戻される値で更新されたかどうか判断する必要がありますが、ブラウザを再読み込みして更新データが表示されれば問題なくデータ更新は反映されています。
emailにも同じ設定
E-mail列の情報も書き換えができるように下記のように設定を行います。
<td v-if="!isEditEmail" v-on:dblclick="isEditEmail = true">{{ user.email }}</td><td v-else><input type="text" class="form-control" v-model="user.email" v-on:blur="updateEmail(user.id, user.email)" ></td>
email用のupdateEmailも追加します。
updateEmail : function(id,email){
axios.patch('http://127.0.0.1:8000/api/users/' + id, {id : id, email: email})
.then((response) => {
this.isEditEmail = false
console.log(response)
}).catch((error) =>{
console.log(error)
})
},
これでnameとemailのセルをダブルクリックすると変更ができるようになりました。
今回記述したコード
下記が今回作成したindex.htmlファイルの全体コードです。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Vue.js</title>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" >
</head>
<body>
<div id="app">
<table class="table table-bordered">
<thead class="thead-dark">
<tr>
<th>ID</th>
<th>ユーザ名</th>
<th>E-mail</th>
<th>削除</th>
</tr>
</thead>
<tbody>
<tr is="user-table" v-for="(user,index) in users" v-bind:user="user" v-bind:key="user.id" v-bind:index="index" v-on:from-child="deleteRow"></tr>
</tbody>
</table>
</div>
<script>
Vue.component('user-table',{
template : `<tr><td>{{ user.id }}</td>
<td v-if="!isEditName" v-on:dblclick="isEditName = true">{{ user.name }}</td>
<td v-else><input type="text" class="form-control" v-model="user.name" v-on:blur="updateName(user.id, user.name)" ></td>
<td v-if="!isEditEmail" v-on:dblclick="isEditEmail = true">{{ user.email }}</td><td v-else><input type="text" class="form-control" v-model="user.email" v-on:blur="updateEmail(user.id, user.email)" ></td>
<td><button class="btn btn-danger" v-on:click="deleteRow(user.id,index)">削除</button></td>
</tr>`,
data: function(){
return {
isEditName : false,
isEditEmail : false
}
},
props : ['user','index'],
methods: {
deleteRow : function(id,index){
axios.delete('http://127.0.0.1:8000/api/users/' + id)
.then((response) => {
this.$emit('from-child',index)
}).catch((error) =>{
console.log(error)
})
},
updateName : function(id,name){
axios.patch('http://127.0.0.1:8000/api/users/' + id, {id : id, name: name})
.then((response) => {
this.isEditName = false
}).catch((error) =>{
console.log(error)
})
},
updateEmail : function(id,email){
axios.patch('http://127.0.0.1:8000/api/users/' + id, {id : id, email: email})
.then((response) => {
this.isEditEmail = false
console.log(response)
}).catch((error) =>{
console.log(error)
})
},
},
});
var app = new Vue({
el: '#app',
data: {
users: []
},
methods: {
deleteRow : function(index){
this.$delete(this.users,index)
}
},
mounted: function(){
axios.get('http://127.0.0.1:8000/api/users').then(response => this.users = response.data);
}
})
</script>
</body>
</html>