この記事は エムスリー Advent Calendar 2017 の21日目の記事です。
2017/12/21 15:40頃追記
多くの方に記事を見て頂きありがとうございます。
誤字脱字の修正などを行いました。
また、シンタックスハイライトの修正の編集リクエストを下さった方に御礼申し上げます。非常に助かりました。
QAグループに所属しているエンジニアです。
近頃、所属しているチームのエンジニアのリーダーがVue.jsやKotlinの普及にとても力を入れており、その熱意に感化される形でプライベートの日々の情報収拾や学習はそれらに関することに充てていました。
それがある程度形になってきたので、今回はVue.jsで作った簡単なアプリ(もどき)を作成して得た知見などをご紹介したいと思います。
筆者のスキル
- Ruby: 期間としては約2年程度学習中でSelenium WebDriverを使用してQA業務でE2Eテストをちょろっと書ける程度
- Kotlin: 黒ベコ本や赤ベコ本、Kotlinイン・アクションなどでまったり学習中(Javaの経験はほとんど無し)
- Vue.js: 学習を始めたばかり(JavaScriptやjQueryなどの経験はほとんど無し)
- QA歴: 12年くらい?
- macOS歴: 約1年
- Qiita投稿歴: 2回目
サンプルコード
GitLab: https://gitlab.com/takayamag/vuejs-rakuten-api/tree/master
サイトイメージ
サンプルサイトの内容
- キーワードによる宿泊施設検索
- 地域・宿泊日程・宿泊人数の複数の条件を組み合わせた宿泊施設検索
- 検索条件と検索結果を元にして宿泊プランの検索と表示
- 各検索結果は1ページに最大10件、10件より多い場合はページネーションによるページの切り替えを行う
- データソース
- 楽天トラベル地区コードAPI
- 楽天トラベルキーワード検索API
- 楽天トラベル施設検索API
- 楽天トラベル空室検索API
サンプルコードで使用したライブラリ
- node.js: 9.3.0
- npm: 5.6.0
- axios: 0.17.1
- 楽天トラベルのAPIとの通信を行うために使用する
- bootstrap: 4.0.0-beta.2
- サイト全体のデザインを整える
- element-ui: 2.0.8
- Vue.jsのコンポーネントライブラリでデザインに気を取られずにロジック部の実装に注力することが出来る
- vue: 2.5.13
- vuex: 3.0.1
- コンポーネント間でデータのやり取りを行いやすくするためのデータストア
vue-cliを使用したプロジェクトの作成
今回、詳細は省略しますが、以下のようにvue-cli
を使用してプロジェクトを作成しました。
vue-router
はYesで、UnitテストとE2Eテストは実装しないのでNoにしました。
# vue-cliが未インストールの時
$ npm install -g vue-cli
# vue-cli
$ vue init webpack vuejs-rakuten-api
? Project name vuejs-rakuten-api
? Project description A Vue.js project
? Author your-alias@example.com
? Vue build standalone
? Install vue-router? Yes
? Use ESLint to lint your code? Yes
? Pick an ESLint preset Standard
? Set up unit tests No
? Setup e2e tests with Nightwatch? No
? Should we run `npm install` for you after the project has been created? (recommended) npm
# $ npm install が実行される
# サイトを実行する時
$ cd vuejs-rakuten-api
$ npm run dev
ディレクトリ構造
config
├── dev.env.js
├── index.js
└── prod.env.js
src
├── App.vue
├── assets
│ └── css
│ └── reset.css
├── components
│ ├── AreaSelect.vue
│ ├── GlobalHeader.vue
│ ├── HotelPanel.vue
│ ├── HotelPlanList.vue
│ ├── KeywordSearchForm.vue
│ ├── MainPage.vue
│ ├── PagenationModule.vue
│ ├── SimpleSearchForm.vue
│ └── UserReview.vue
├── helpers
│ └── utils.js
├── main.js
├── router
│ └── index.js
└── vuex
├── modules
│ ├── hotel-search.js
│ └── mutation-types.js
└── store.js
実装内容
'use strict'
const merge = require('webpack-merge')
const prodEnv = require('./prod.env')
module.exports = merge(prodEnv, {
NODE_ENV: '"development"',
RAKUTEN_API_KEY: '"XXXXXXXXXXXXXXXXXXXXXXX"' // 楽天ウェブサービスのAPIキーと置き換えて下さい
})
▲RAKUTEN_API_KEY
はご自身の楽天ウェブサービスのAPIキーと置き換えて下さい。このキーにはprocess.env.RAKUTEN_API_KEY
でアクセスすることが出来ます。
なお、楽天APIへのリクエストはクライアント(ブラウザー)で行うため、HTTPリクエストにAPIキーが含まれており、このままではリリース出来ません。よって、サーバーサイドからAPIへのアクセスを行う必要がありそうです。
import Vue from 'vue'
import App from './App'
import router from './router'
// ElementUI: http://element.eleme.io/#/en-US
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
import locale from 'element-ui/lib/locale/lang/ja'
// Bootstrap: https://github.com/bootstrap-vue/bootstrap-vue
import 'bootstrap/dist/css/bootstrap.css'
// Vuex
import store from './vuex/store'
Vue.use(ElementUI, { locale })
Vue.config.productionTip = false
/* eslint-disable no-new */
new Vue({
store,
el: '#app',
router,
template: '<App/>',
components: { App }
})
▲ElementUI、Bootstrap、Vuexを使用するため必要な記述を追加していきます。基本的に公式サイトなどの導入ガイドのままです。
// axios: https://github.com/axios/axios
import axios from 'axios'
export function openExternalSite (url) {
window.open(url)
}
// yyyy-MM-dd の形式で文字列を返すメソッド
export function getDateAsYmd (offset = 0) {
let d = new Date()
// 当日からの差分の日付をセットする
if (offset !== 0) {
d.setDate(d.getDate() + offset)
}
// ローカルタイムで処理する
let year = d.getFullYear()
let month = d.getMonth() + 1
let day = d.getDate()
// yyyy-MM-dd の形式
return (year + '-' + month + '-' + day)
}
// 数値を3桁ごとにカンマ区切りにする
// 変換出来ない値の時は 'ー' の文字列を返す
export function chargeToLocalString (charge) {
return charge ? charge.toLocaleString() : 'ー'
}
// 消費税込みで数値を3桁ごとにカンマ区切りにする
// 変換出来ない値の時は 'ー' の文字列を返す
export function chargeToLocalStringWithTax (charge) {
if (charge) {
const CURRENT_TAX = 1.08
const CHARGE_WITH_TAX = parseInt(charge) * CURRENT_TAX
return Math.round(CHARGE_WITH_TAX).toLocaleString()
} else {
return 'ー'
}
}
// axiosのライブラリを使用して楽天APIへリクエストを送る
export function requestRakutenApi (apiPath, params) {
let instance = axios.create({
baseURL: 'https://app.rakuten.co.jp/services/api',
timeout: 3000,
method: 'GET',
responseType: 'json', // レスポンスはjsonオブジェクト固定
params: params
})
return instance.get(apiPath, {})
.then(response =>
response.data
).catch(error => {
console.log(error.response.status)
console.log(error.response.data)
return null
})
}
▲サイト内で使い回すJavaScriptのメソッドをヘルパーとして切り出しています。
後ほど出てきますが、import { getDateAsYmd, requestRakutenApi } from '../../helpers/utils'
といった書き方でimportすると、そのメソッドを呼び出すことが出来ます。
// ミューテーションタイプを定義する
export const UPDATE_AREA_CODE = 'UPDATE_AREA_CODE'
export const UPDATE_RESULT_STATE = 'UPDATE_RESULT_STATE'
export const UPDATE_CURRENT_PAGE = 'UPDATE_CURRENT_PAGE'
export const UPDATE_SEARCH_MODE = 'UPDATE_SEARCH_MODE'
▲Vuexのmutationsで使用するミューテーションタイプを定義します。まだ、よく分かっていませんが定数を用いることでコードの見通しが良くなるようです。(要復習)
import { getDateAsYmd, requestRakutenApi } from '../../helpers/utils'
import * as types from './mutation-types'
const HotelSearch = {
namespaced: true, // ネームスペースをtrueにすることで、HotelSearch/{action_name}
state: {
searchMode: 0, // KeywordHotelSearch: 0, SimpleHotelSearch: 1
areaCode: [], // 楽天トラベル地区コードAPIのレスポンスを保持する
conditions: {
keyword: '', // 入力したキーワードを保持する
area: {
middle: '', // 選択中の都道府県コードを保持する
small: '', // 選択中の市区町村コードを保持する
detail: '' // 選択中の駅や詳細地域コードを保持する
},
adultNum: 2, // 宿泊時の人数を保持する (デフォルト値は2)
visitDuration: // 宿泊日データをyyyy-MM-dd形式で保持する
[
getDateAsYmd(), // 今日の日付をチェックインのデフォルト値とする
getDateAsYmd(1) // 翌日の日付をチェックアウトのデフォルト値とする
],
currentPage: 1 // 現在のページインデックスを保持する (デフォルト値は1)
},
result: {
pagingInfo: {}, // 検索結果の総数などを保持する
hotels: [] // 検索結果の宿泊施設情報などを保持する
}
},
getters: {
// 各stateを参照するためのgetterを指定する
conditions: state => state.conditions,
pagingInfo: state => state.result.pagingInfo,
hotels: state => state.result.hotels,
areaCode: state => state.areaCode
},
mutations: {
// 楽天トラベル地区コードAPIのレスポンスをコミットする
// 基本的にアプリケーション起動時に一度だけ取得する
[types.UPDATE_AREA_CODE] (state, data) {
state.areaCode = data
},
// 宿泊施設のデータや検索結果のサマリーなどをコミットする
[types.UPDATE_RESULT_STATE] (state, data) {
state.result.hotels = data.hotels
state.result.pagingInfo = data.pagingInfo
},
// 現在のページインデックスをコミットする
[types.UPDATE_CURRENT_PAGE] (state, currentPage) {
state.conditions.currentPage = currentPage
},
// 現在の検索モード(APIの使い分け)の状態をコミットする
[types.UPDATE_SEARCH_MODE] (state, id) {
state.conditions.searchMode = id
}
},
actions: {
// 楽天トラベル地区コードAPIへリクエストを送信し、stateを更新する
getAreaClass ({ commit, state }) {
let params = {
applicationId: process.env.RAKUTEN_API_KEY,
format: 'json',
formatVersion: '2',
elements: 'middleClasses' // 都道府県、市区町村、詳細地域に絞ってレスポンスを返すように指定する
}
const API_PATH = '/Travel/GetAreaClass/20131024'
requestRakutenApi(API_PATH, params)
.then(data => {
if (data !== null) {
commit(types.UPDATE_AREA_CODE, data)
}
})
},
// 楽天トラベルキーワード検索APIへリクエストを送信してレスポンスを取得する
keywordHotelSearch ({ commit, state }) {
let params = {
applicationId: process.env.RAKUTEN_API_KEY,
format: 'json',
keyword: state.conditions.keyword, // フォームで入力したキーワードの文字列を渡す
formatVersion: '2',
datumType: '1', // 世界測地系、単位は度
hits: '10', // 最大10件のレスポンスを得る(最大30件まで指定出来る)
page: state.conditions.currentPage, // どのページを起点に検索するかを指定する
responseType: 'middle', // 中程度の情報量を返却することをリクエストする
sort: 'standard' // レスポンスはキーワード適中率が高い順でソートする
}
const API_PATH = '/Travel/KeywordHotelSearch/20170426'
requestRakutenApi(API_PATH, params)
.then(data => {
if (data !== null) {
commit(types.UPDATE_RESULT_STATE, data)
}
})
},
// 楽天トラベル空室検索APIへリクエストを送信してレスポンスを取得する
simpleHotelSearch ({ commit, state }) {
let params = {
applicationId: process.env.RAKUTEN_API_KEY,
format: 'json',
largeClassCode: 'japan', // 日本固定
middleClassCode: state.conditions.area.middle, // 都道府県など
smallClassCode: state.conditions.area.small, // 市区町村など
detailClassCode: state.conditions.area.detail, // 駅や詳細地域など
formatVersion: '2',
datumType: '1', // 世界測地系、単位は度
hits: '10', // 最大10件のレスポンスを得る(最大30件まで指定出来る)
page: state.conditions.currentPage, // どのページを起点に検索するかを指定する
responseType: 'middle', // 中程度の情報量を返却することをリクエストする
sort: 'standard' // レスポンスはキーワード適中率が高い順でソートする
}
const apiPath = '/Travel/SimpleHotelSearch/20170426'
requestRakutenApi(apiPath, params)
.then(data => {
if (data !== null) {
commit(types.UPDATE_RESULT_STATE, data)
}
})
}
}
}
export default HotelSearch
▲Vuexのストアを作成します。
const HotelSearch = {
namespaced: true,
state: {},
getters: {},
mutations: {},
actions: {}
}
export default HotelSearch
▲上記のようにnamespaces: true
とすることでストアをモジュール化することが出来ます。そうすると、this.$store.dispatch('HotelSearch/keywordHotelSearch')
というようにネームスペース付きでactionを実行することが出来るので、同名のactionが存在する時に衝突を避けることが出来るようです。
import Vue from 'vue'
import Vuex from 'vuex'
import HotelSearch from './modules/hotel-search'
Vue.use(Vuex)
const store = new Vuex.Store({
modules: {
HotelSearch
}
})
export default store
▲HotelSearch
モジュールはstore.js
内で上記のように読み込みます。
Vuexのストア部分が出来たので、Vueのコンポーネントを作っていきます。
まずはトップページから。
<template>
<el-container>
<el-header>
<global-header />
</el-header>
<el-main>
<div style="padding: 16px; border: 2px solid rgb(84, 92, 100); padding: 10px; border-radius: 25px;">
<div>
<h1 style="margin-bottom: 32px;">キーワード検索API</h1>
<keyword-search-form />
</div>
<hr />
<div>
<h1 style="margin-bottom: 32px;">施設検索API</h1>
<simple-search-form />
</div>
<pagenation-module style="margin: 16px;" v-show="hotels.length != 0" />
<hr />
<div v-if="hotels.length != 0">
<!--
ElementのBuilt-in transitionを利用する。
複数の要素に適用する場合はtransition-groupを使用する。
http://element.eleme.io/#/en-US/component/transition
-->
<transition-group name="el-fade-in-linear">
<hotel-panel
v-for="(hotel, index) in hotels"
:hotelBasicInfo="hotel[0].hotelBasicInfo"
:hotelRatingInfo="hotel[1].hotelRatingInfo"
:key="index" />
</transition-group>
</div>
<div v-else>
検索条件を指定して、「検索する」ボタンを押して下さい。
</div>
<hr />
<pagenation-module style="margin: 16px;" v-show="hotels.length != 0" />
</div>
</el-main>
</el-container>
</template>
<script>
import GlobalHeader from '@/components/GlobalHeader'
import SimpleSearchForm from '@/components/SimpleSearchForm'
import HotelPanel from '@/components/HotelPanel'
import KeywordSearchForm from '@/components/KeywordSearchForm'
import PagenationModule from '@/components/PagenationModule'
import { mapGetters } from 'vuex'
export default {
components: {
GlobalHeader,
KeywordSearchForm,
SimpleSearchForm,
HotelPanel,
PagenationModule
},
computed: {
...mapGetters('HotelSearch', ['hotels'])
}
}
</script>
<style>
.el-header, .el-footer {
padding: 0px;
background-color: #B3C0D1;
color: #333;
text-align: center;
line-height: 60px;
}
.el-main {
background-color: #fff;
color: #333;
}
</style>
▲MainPage.vue
は簡略化すると以下のようなモジュール構成(階層)になっています。どのコンテントをモジュールとするのが良いのかは結構悩みました。結局、汎用性よりも実装のしさすさを優先しています。もっと理解を深め、経験を積めばVue.jsらしさを押し出しながら、再利用性や堅牢性を兼ね備えた各機能を実装出来るようになるのかも知れません。そう考えると夢が広がってワクワクしてきますね。
- <global-header />
- <keyword-search-form />
- <simple-search-form />
- <area-select />
- <pagenation-module />
- <hotel-panel />
- <user-review />
- <hotel-plan-list />
- <pagenation-module />
以下の記述は、getterをスプレッド演算子を使用してcomputedに組み込んでstate.hotels
を参照出来るようにしています。...
は最初に見た時は何かの省略記号かと思ったのですが、ちゃんと意味と名前があるんですね。
computed: {
...mapGetters('HotelSearch', ['hotels'])
}
ページネーションでは、以下のようにv-show
を使用して表示する宿泊施設データがある時のみ表示するようにしています。
<pagenation-module style="margin: 16px;" v-show="hotels.length != 0" />
検索結果の表示部分でも同様にv-if
とv-else
を使用して宿泊施設データの有無で表示の出し分けを行っています。さりげなく<transition-group name="el-fade-in-linear">
でElementUIのBuilt-in transitionを使用しています。transitionをかける要素が複数の場合は<transition-group>
を使用します。(エフェクト自体はあまり効果的とは言い難い気もしますが…)
<div v-if="hotels.length != 0">
<transition-group name="el-fade-in-linear">
<hotel-panel
v-for="(hotel, index) in hotels"
:hotelBasicInfo="hotel[0].hotelBasicInfo"
:hotelRatingInfo="hotel[1].hotelRatingInfo"
:key="index" />
</transition-group>
</div>
<div v-else>
検索条件を指定して、「検索する」ボタンを押して下さい。
</div>
<template>
<!-- ページ最上部に表示するヘッダー -->
<div>
<el-menu
:default-active="activeIndex"
mode="horizontal"
background-color="#545c64"
text-color="#fff"
active-text-color="#ffd04b">
<el-menu-item index="1"><a href="/"><strong>Vue.js Demo</strong></a></el-menu-item>
<el-menu-item index="2"><a href="https://jp.vuejs.org/" target="_blank">Vue.js 公式サイト</a></el-menu-item>
<el-menu-item index="3"><a href="https://vuex.vuejs.org/ja/" target="_blank">Vuex 公式サイト</a></el-menu-item>
<el-menu-item index="4"><a href="http://element.eleme.io/#/en-US" target="_blank">Element 公式サイト</a></el-menu-item>
<el-menu-item index="5"><a href="https://webservice.rakuten.co.jp/document/" target="_blank">楽天サービスAPI一覧</a></el-menu-item>
<el-menu-item index="6"><a href="https://github.com/axios/axios" target="_blank">axios</a></el-menu-item>
<el-menu-item index="7"><a href="https://www.npmjs.com/package/async-validator" target="_blank">async-validator</a></el-menu-item>
</el-menu>
</div>
</template>
<script>
export default {
data () {
return {
activeIndex: '1'
}
}
}
</script>
▲ヘッダー部分のコンポーネントはほぼサンプルコードのままなので特に見るべきところはないですね。
何も無いのも味気ないので使用したライブラリへのリンク集となっています。
<template>
<div style="width: 960px;">
<el-form
:model="conditions"
label-width="100px"
size="large"
:status-icon="true"
:rules="rules"
ref="form">
<el-row :gutter="20">
<el-col :span="18">
<el-form-item prop="keyword" label="検索ワード">
<el-input
v-model="conditions.keyword"
placeholder="地域やホテル名などを入力(例: 京都 嵐山) [単語を半角スペースで区切ると複数条件検索]">
</el-input>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item>
<el-button
style="width: 300px;"
type="primary"
round
@click="submitForm('form')">
<strong>検索する</strong>
</el-button>
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
data () {
return {
// async-validatorを使用してvalidationを行う
rules: {
keyword: [ // el-form-itemのpropの値と一致させる
// 未入力は許可しない
{ required: true, message: 'キーワードを入力して下さい', trigger: 'blur' },
// APIには2文字以上、80文字以内の制約があるのでバリデーションルールを揃える
{ min: 2, max: 80, message: 'キーワードを2文字以上80文字以下で入力して下さい', trigger: 'blur' }
]
}
}
},
computed: {
// 検索条件のstateにアクセス出来るようにする
// mutationを使用せず直接更新する
...mapGetters('HotelSearch', ['conditions'])
},
methods: {
// el-formのrefの値を引数として渡す
submitForm (formName) {
this.$refs[formName].validate((valid) => {
if (valid) {
this.$store.commit('HotelSearch/UPDATE_SEARCH_MODE', 0) // 検索モードの指定
this.$store.commit('HotelSearch/UPDATE_CURRENT_PAGE', 1) // 検索後のページインデックスは1にリセットする
this.$store.dispatch('HotelSearch/keywordHotelSearch') // キーワード検索APIのレスポンスを得る
} else {
return false
}
})
}
}
}
</script>
▲キーワード検索APIを行うためのフォームの実装です。el-input
でv-model="conditions.keyword"
とすることで、stateをmutationを経由せずに直接更新しています。良し悪しがあると思いますが、mutationを経由すると処理が煩雑になるので今回はこのようにしました。
<el-input
v-model="conditions.keyword"
placeholder="地域やホテル名などを入力(例: 京都 嵐山) [単語を半角スペースで区切ると複数条件検索]">
</el-input>
ElementUIはValidatorとしてasync-validator
を使用しているのですが、利用する場合は以下のように記述します。簡単にバリデーションを記述出来ると思ったのですが、複数の項目の状態に応じて処理が分岐するようなバリデーションを書こうとしたところ実装方法が分からずに挫折しました。どなたかご存知でしたら教えて下さい…
<el-form
〜〜略〜〜
:rules="rules"
ref="form">
〜〜略〜〜
<el-form-item prop="keyword" label="検索ワード">
<el-input
〜〜略〜〜
<el-form-item>
<el-button
@click="submitForm('form')">
〜〜略〜〜
data () {
return {
rules: {
keyword: [ // el-form-itemのpropの値と一致させる
// 未入力は許可しない
{ required: true, message: 'キーワードを入力して下さい', trigger: 'blur' },
// APIには2文字以上、80文字以内の制約があるのでバリデーションルールを揃える
{ min: 2, max: 80, message: 'キーワードを2文字以上80文字以下で入力して下さい', trigger: 'blur' }
〜〜略〜〜
methods: {
// el-formのrefの値を引数として渡す
submitForm (formName) {
this.$refs[formName].validate((valid) => {
if (valid) {
// Do Something
} else {
return false
}
})
}
}
次に、地域・宿泊日程・宿泊人数の条件が指定出来る宿泊施設検索フォームを実装します。
<template>
<div style="width: 960px;">
<el-form
:model="conditions"
label-width="100px"
size="large"
:status-icon="true"
:rules="rules"
ref="form">
<el-row :gutter="20">
<el-col :span="24">
<el-form-item size="30" prop="areaCode" label="都道府県">
<area-select />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item prop="visitDuration" label="宿泊日程">
<el-date-picker
v-model="conditions.visitDuration"
type="daterange"
value-format="yyyy-MM-dd"
range-separator="〜"
start-placeholder="チェックイン"
end-placeholder="チェックアウト">
</el-date-picker>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="adultNum" label="人数">
<el-input-number
v-model="conditions.adultNum"
:min="1"
:max="10">
</el-input-number>
</el-form-item>
</el-col>
</el-row>
<el-form-item>
<el-button
style="width: 300px;"
type="primary"
round
@click="submitForm('form')">
<strong>検索する</strong>
</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script>
import AreaSelect from '@/components/AreaSelect'
import { mapGetters } from 'vuex'
export default {
components: {
AreaSelect
},
data () {
return {
rules: {
// 宿泊人数のバリデーション
// ここでは1〜10までを入力可とする
adultNum: [
{ required: true, message: '1〜10までを入力して下さい', trigger: 'blur' }
],
// カレンダーのバリデーション
// TODO: 同日を指定した場合や、チェックイン日>チェックアウト日の時にエラーとしたい
visitDuration: [
{ required: true, message: '宿泊日程を入力して下さい', trigger: 'blur' }
]
}
}
},
computed: {
...mapGetters('HotelSearch', ['conditions'])
},
methods: {
submitForm (formName) {
this.$refs[formName].validate((valid) => {
if (valid) {
this.$store.commit('HotelSearch/UPDATE_SEARCH_MODE', 1) // 検索モードの指定
this.$store.commit('HotelSearch/UPDATE_CURRENT_PAGE', 1) // 検索後のページインデックスは1にリセットする
this.$store.dispatch('HotelSearch/simpleHotelSearch') // 施設検索APIのレスポンスを得る
} else {
return false
}
})
}
}
}
</script>
▲バリデーションはKeywordSearchForm.vue
と同じような実装なので省略して、新しく出てきたElementUIのコンポーネントを見ていきます。
<!-- el-date-picker -->
<el-form-item prop="visitDuration" label="宿泊日程">
<el-date-picker
v-model="conditions.visitDuration"
type="daterange"
value-format="yyyy-MM-dd"
range-separator="〜"
start-placeholder="チェックイン"
end-placeholder="チェックアウト">
</el-date-picker>
</el-form-item>
〜〜略〜〜
<!-- el-input-number -->
<el-form-item prop="adultNum" label="人数">
<el-input-number
v-model="conditions.adultNum"
:min="1"
:max="10">
</el-input-number>
</el-form-item>
左がDate Pickerで、右がInput Numberとなります。このようなグラフィカルなコンポーネントを手軽に導入出来るのがElementUIの利点ですね。
こちらのフォームでも入力値はstateをバインドしてmutationを介さないで更新しています。type="daterange"
のel-date-picker
で注意するのは値が配列になっているので、date[0]
やdate[1]
でアクセスする必要があることですね。
<template>
<div style="float: left;">
<!-- 都道府県 -->
<el-select
@change="changedMiddleArea"
v-model="conditions.area.middle"
size="24"
placeholder="都道府県を選択">
<el-option
v-for="item in options.middleArea"
:key="item.value"
:label="item.label"
:value="item.value" />
</el-select>
<!-- 市区町村 -->
<el-select
@change="changedSmallArea"
v-model="conditions.area.small"
size="32"
style="margin-left: 20px;"
placeholder="市区町村を選択">
<el-option
v-for="item in options.smallArea"
:key="item.value"
:label="item.label"
:value="item.value" />
</el-select>
<!-- 詳細地域 -->
<!-- 詳細地域が存在しない場合はドロップダウンを表示しない -->
<el-select
v-show="options.detailArea.length != 0"
v-model="conditions.area.detail"
size="32"
style="margin-left: 20px;"
placeholder="詳細地域を選択">
<el-option
v-for="item in options.detailArea"
:key="item.value"
:label="item.label"
:value="item.value" />
</el-select>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
data () {
return {
middleClasses: [], // 楽天トラベル地区コードAPIのmiddleClassesノード以下を保持する
options: {
middleArea: [], // 都道府県を示すコードとラベルのリスト
smallArea: [], // 市区町村などを示すコードとラベルのリスト
detailArea: [] // 駅や詳細地域などを示すコードとラベルのリスト
}
}
},
created () {
// 楽天トラベル地区コードAPIのレスポンスを取得する
this.$store.dispatch('HotelSearch/getAreaClass')
},
watch: {
// 楽天トラベル地区コードAPIのレスポンスが取得出来たらドロップダウンの初期化処理を行う
areaCode: function () {
if (this.areaCode !== []) {
this.middleClasses = this.areaCode.areaClasses.largeClasses[0][0].middleClasses
this.initialize()
}
}
},
computed: {
...mapGetters('HotelSearch', ['areaCode']),
...mapGetters('HotelSearch', ['conditions'])
},
methods: {
// 都道府県のドロップダウンを初期化する
initialize () {
this.options.middleArea = []
for (var mc of this.middleClasses) {
this.options.middleArea.push(
{
value: mc.middleClass[0].middleClassCode,
label: mc.middleClass[0].middleClassName
}
)
}
},
// 都道府県のドロップダウンの選択項目が変化した時に実行される
// 選択された都道府県コードに応じて対応する市区町村のドロップダウンを更新する
changedMiddleArea () {
this.options.smallArea = [] // 市区町村のドロップダウンをクリア
this.conditions.area.small = '' // 選択中の市区町村コードをクリア
this.conditions.area.detail = '' // 選択中の詳細地域コードをクリア
let index = 0 // 都道府県のインデックス(0〜47の範囲を取る)
// 選択中の都道府県のインデックスを取得する
for (var ma of this.options.middleArea) {
if (ma.value === this.conditions.area.middle) {
break
}
index++
}
let smallClasses = this.middleClasses[index].middleClass[1].smallClasses
// 選択中の都道府県に紐づく市区町村リストをドロップダウンにセットする
for (var sc of smallClasses) {
this.options.smallArea.push(
{
value: sc.smallClass[0].smallClassCode,
label: sc.smallClass[0].smallClassName
}
)
}
},
// 市区町村のドロップダウンの選択項目が変化した時に実行される
// 選択された市区町村コードに応じて対応する詳細地域のドロップダウンを更新する
// なお、詳細地域は存在しないことの方が多い
changedSmallArea () {
this.options.detailArea = [] // 選択中の詳細地域コードをクリア
this.conditions.area.detail = '' // 選択中の詳細地域コードをクリア
let index = 0 // 都道府県のインデックス(0〜47の範囲を取る)
// 選択中の都道府県のインデックスを取得する
for (var ma of this.options.middleArea) {
if (ma.value === this.conditions.area.middle) {
break
}
index++
}
let smallClasses = this.middleClasses[index].middleClass[1].smallClasses
for (var sc of smallClasses) {
// 選択中の市区町村ノードを対象に処理を行う
if (sc.smallClass[0].smallClassCode === this.conditions.area.small) {
// 詳細地域ノードが存在するかどうか調べる
if (sc.smallClass.length !== 1) { // detailClassesノードが存在する時
// 選択中の市区町村に紐づく詳細地域リストをドロップダウンにセットする
for (var dc of sc.smallClass[1].detailClasses) {
this.options.detailArea.push(
{
value: dc.detailClass.detailClassCode,
label: dc.detailClass.detailClassName
}
)
}
}
break // detailClassesノードが存在しない時はループを抜ける
}
}
}
}
}
</script>
▲次に、SimpleSearchForm.vue
の地域を選択するためのドロップダウンコンポーネントを作ります。このコンポーネントは初回にインスタンスを生成する時に地区コードAPIからレスポンスを受け取って動的に生成しているため動かすのに苦労した箇所の一つです。
見た目はこのような感じになります。3つ目の詳細地域は存在しないケースが多いので、その時は非表示にしています。
JSON
形式のレスポンスを地道にパースしているのですが、絶対にスマートな方法があると思っているので、良い方法がありましたら是非教えて下さい。もう、JSONのパースで疲れるのは嫌だ…
なお、ここでasync-validator
を使ったバリデーションの方法が今のところ分かっていません。「都道府県+市区町村」の2つまたは「都道府県+市区町村+詳細地域」の3つが揃ったらパス…みたいなバリデーションはどう実装するんだろう…
<template>
<div>
<el-card style="margin-bottom: 16px;">
<div slot="header" class="clearfix">
<h1 style="font-size: 20pt;">
<a :href="hotelBasicInfo.hotelInformationUrl" target="_blank">
{{ hotelBasicInfo.hotelName }} <span style="font-size: small;">({{ hotelBasicInfo.hotelKanaName }})</span>
</a>
</h1>
</div>
<el-row :gutter="20">
<el-col :span="6">
<div style="width: 240px;" class="box">
<img class="img" :src="hotelBasicInfo.hotelImageUrl" :width="220" :height="220">
</div>
</el-col>
<el-col :span="12">
<div style="width: 560px;" class="text item box">
<div style="text-align: left;">
[施設の特徴]<br>
<p>{{ hotelBasicInfo.hotelSpecial }}</p>
<p>
[最安料金] <span>{{ hotelMinCharge(hotelBasicInfo.hotelMinCharge) }}円〜</span>
<span>(消費税込 {{ hotelMinChargeWithTax(hotelBasicInfo.hotelMinCharge) }}円〜)</span>
</p>
<p>
<span>[所在地] 〒{{ hotelBasicInfo.postalCode }} {{ hotelBasicInfo.address1 }} {{ hotelBasicInfo.address2 }}</span>
<a @click="dialogVisible = true" style="color: #007bff; text-decoration: none; cursor: pointer;">
<strong>[地図を見る]</strong>
</a>
<br>
[アクセス] {{ hotelBasicInfo.access }}<br>
[駐車場] {{ hotelBasicInfo.parkingInformation }}
</p>
<!-- 地図を表示するモーダルダイアログ -->
<el-dialog
:title="hotelBasicInfo.hotelName"
:visible.sync="dialogVisible"
width="60%">
<span>
<iframe
width="640"
height="480"
:src="hotelMapUrl(hotelBasicInfo.hotelName, hotelBasicInfo.longitude, hotelBasicInfo.latitude)">
</iframe>
</span>
<span slot="footer" class="dialog-footer">
<el-button type="warning" @click="dialogVisible = false">
<strong>地図を閉じる</strong>
</el-button>
</span>
</el-dialog>
<!-- モーダルダイアログここまで -->
<div style="display: inline; text-align: center;">
<el-button
style="width: 300px;"
type="success"
round
@click="visitRakutenTravel(hotelBasicInfo.hotelInformationUrl)">
<strong>楽天トラベルで詳細を見る</strong>
</el-button>
</div>
</div>
</div>
</el-col>
<el-col :span="6">
<div>
<div>
<!--
UserReviewコンポーネントで参照するオブジェクトを指定する
-->
<user-review
:hotelRatingInfo="hotelRatingInfo"
:hotelBasicInfo="hotelBasicInfo" />
</div>
</div>
</el-col>
</el-row>
<!--
空室検索検索モードの時のみHotelPlanListコンポーネントを表示する
-->
<el-row style="margin-top: 16px;" v-if="conditions.searchMode === 1">
<el-col :span="24">
<!--
宿泊施設を一意に特定出来るようにするためにパネルに表示中のhotelNoを渡す
-->
<hotel-plan-list :hotelNo="hotelBasicInfo.hotelNo" />
</el-col>
</el-row>
</el-card>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import UserReview from '@/components/UserReview'
import HotelPlanList from '@/components/HotelPlanList'
import {
chargeToLocalString,
chargeToLocalStringWithTax,
openExternalSite
} from '../helpers/utils'
export default {
components: {
UserReview,
HotelPlanList
},
props: {
hotelBasicInfo: { type: Object },
hotelRatingInfo: { type: Object }
},
data () {
return {
dialogVisible: false // モーダルダイアログの表示状態を制御するフラグ
}
},
computed: {
...mapGetters('HotelSearch', ['conditions'])
},
methods: {
// 最安料金を3桁で半角カンマ区切りの文字列で返す
hotelMinCharge (charge) {
return chargeToLocalString(charge)
},
// 最安料金を3桁で半角カンマ区切り、かつ消費税込みの文字列で返す
hotelMinChargeWithTax (charge) {
return chargeToLocalStringWithTax(charge)
},
// 楽天トラベルのGoogleマップ表示用ページをお借りする
// この地図はモーダルウィンドウのiframe内に表示する
hotelMapUrl (name, lon, lat) {
const BASE_URL = 'http://travel.rakuten.co.jp/share/gmap/map.html?'
const PATH = 'f_longitude=' + lon + '&f_latitude=' + lat + '&height=450&width=600'
return BASE_URL + PATH
},
// 「楽天トラベルで詳細を見る」ボタンで該当宿泊施設ページを別ウィンドウで開く
visitRakutenTravel (url) {
openExternalSite(url)
}
}
}
</script>
<style>
.img {
border-radius: 10px;
-webkit-border-radius: 10px;
-moz-border-radius: 10px;
box-shadow: 0px 0px 20px -5px rgba(0, 0, 0, 0.8);
}
.text {
font-size: 14px;
}
.item {
margin-bottom: 18px;
}
.box {
float: left;
margin: 8px ;
}
/* clearfix */
.boxContainer:before,
.boxContainer:after {
content: "";
display: table;
}
.boxContainer:after {
clear: both;
}
</style>
▲HotelPanel.vue
は検索で得られた宿泊施設の詳細情報を表示するコンポーネントです。ElementUIのCardコンポーネントを利用しています。内容的にはAPIから得られた宿泊施設情報を表示しているだけです。ちなみに、表示しているデータやレイアウトは楽天トラベルのPCサイトに寄せていたりします。
[地図を見る]
リンクではElementUIのモーダルダイアログを使用してモーダル内に地図を表示しています。
<!-- 地図を表示するモーダルダイアログ -->
<el-dialog
:title="hotelBasicInfo.hotelName"
:visible.sync="dialogVisible"
width="60%">
<span>
<iframe
width="640"
height="480"
:src="hotelMapUrl(hotelBasicInfo.hotelName, hotelBasicInfo.longitude, hotelBasicInfo.latitude)">
</iframe>
</span>
<span slot="footer" class="dialog-footer">
<el-button type="warning" @click="dialogVisible = false">
<strong>地図を閉じる</strong>
</el-button>
</span>
</el-dialog>
<!-- モーダルダイアログここまで -->
見た目は以下のようになります。モーダル内に住所やアクセス方法も記載するとより利便性が上がりそうですね。実はモーダルのレイアウトを上手く制御出来なかったのでカスタマイズは保留です…
次は、HotelPanel.vue
内に表示するレビュー表示用のコンポーネントです。
<template>
<div>
<a target="_blank" :href="hotelBasicInfo.reviewUrl">
[レビュー数({{ hotelBasicInfo.reviewCount }}件)]
</a>
<table style="width: 240px;">
<tr>
<td width="80px">評価平均: </td>
<td>
<el-rate style="font-weight: bold;" disabled show-score v-model="hotelBasicInfo.reviewAverage" />
</td>
</tr>
<tr>
<td>風呂: </td>
<td>
<el-rate disabled show-score v-model="hotelRatingInfo.bathAverage" />
</td>
</tr>
<tr>
<td>設備: </td>
<td>
<el-rate disabled show-score v-model="hotelRatingInfo.equipmentAverage" />
</td>
</tr>
<tr>
<td>場所: </td>
<td>
<el-rate disabled show-score v-model="hotelRatingInfo.locationAverage" />
</td>
</tr>
<tr>
<td>食事: </td>
<td>
<el-rate disabled show-score v-model="hotelRatingInfo.mealAverage" />
</td>
</tr>
<tr>
<td>部屋: </td>
<td>
<el-rate disabled show-score v-model="hotelRatingInfo.roomAverage" />
</td>
</tr>
<tr>
<td>サービス: </td>
<td>
<el-rate disabled show-score v-model="hotelRatingInfo.serviceAverage" />
</td>
</tr>
</table>
</div>
</template>
<script>
export default {
props: {
hotelRatingInfo: { type: Object }, // 宿泊施設に属する口コミのレーティング情報を参照する
hotelBasicInfo: { type: Object } // 宿泊施設の口コミの平均レーティング情報を参照する
}
}
</script>
▲UserReview.vue
は、レビューを表示するコンポーネントです。レビューの点数に応じてElementUIのRateモジュールを使用して星マークを表示しています。
見た目はこのような感じになります。レイアウトがガタガタしているのは気になるけど気にしないことにします…実はレーダーチャートで描画しようと色々調べたのですが、意図した表示にならなかったため今後の課題にしたいと思います。
次もHotelPanel.vue
内に表示するコンポーネントですが、このコンポーネントは地域・宿泊日程・宿泊人数を指定する「施設検索API」使用時のみ表示されるようになっています。
<template>
<div>
<!-- 開いた時に該当する宿泊施設の空室検索を行う -->
<el-collapse @change="handleChange">
<el-collapse-item :title="moreText" :name="hotelNo">
<!-- 空室が一つ以上見つかった時にリストを表示する -->
<div v-if="plans.length !== 0">
<el-table
:data="plans"
style="width: 100%">
<el-table-column prop="stayDate" label="宿泊日" width="120">
</el-table-column>
<el-table-column prop="planName" label="プラン名">
</el-table-column>
<el-table-column prop="roomName" label="部屋名" width="200">
</el-table-column>
<el-table-column prop="total" label="宿泊金額(税込)" width="180">
</el-table-column>
<el-table-column prop="reserveUrl" label="プラン詳細" width="240">
<template slot-scope="scope">
<!--
slot-scopeを使用することによって、scope.row.reserveUrlにアクセス出来る
-->
<el-button @click="visitRakutenTravel(scope.row.reserveUrl)" type="warning" round>
<strong>このプランの詳細を見る</strong>
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 空室が一つも見つからなかった時に以下のメッセージを表示する -->
<div v-else>
該当するプランが1つも見つかりませんでした。<br>条件を変更すると他のプランが見つかる可能性があります。
</div>
</el-collapse-item>
</el-collapse>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import {
requestRakutenApi,
chargeToLocalStringWithTax,
openExternalSite
} from '../helpers/utils'
export default {
props: {
hotelNo: { type: Number } // 宿泊施設番号を元に空室検索APIへ問い合わせる
},
data () {
return {
moreText: 'プラン一覧を見る', // 「プラン一覧を見る」と「プラン一覧を閉じる」の表示を切り替える
hotels: [], // 楽天トラベル空室検索APIのレスポンスを格納する
plans: [] // テーブルのデータ表示用に空室検索APIのレスポンスを加工して格納する
}
},
computed: {
...mapGetters('HotelSearch', ['conditions'])
},
watch: {
// 楽天トラベル空室検索APIのレスポンスを取得した時にテーブルの初期化処理を行う
hotels: function () {
if (this.hotels.length !== 0) {
this.setTable(this.hotels[0])
}
}
},
methods: {
// 楽天トラベルのプラン詳細ページを別ウィンドウで開く
visitRakutenTravel (url) {
openExternalSite(url)
},
// 検索条件と宿泊施設に紐づくプラン一覧を表形式で表示するための処理
setTable (data) {
// 配列の0〜2の要素は処理の対象外
if (data.length > 3) {
// 楽天トラベル空室検索APIで取得したレスポンスのroomBasicInfoを加工してリストにする
for (let i = 3; i < data.length; i++) {
let roomBasicInfo = data[i].roomInfo[0].roomBasicInfo
let dailyCharge = data[i].roomInfo[1].dailyCharge
this.plans.push(
{
planName: roomBasicInfo.planName,
roomName: roomBasicInfo.roomName,
reserveUrl: roomBasicInfo.reserveUrl,
stayDate: dailyCharge.stayDate,
total: (chargeToLocalStringWithTax(dailyCharge.total) + ' 円')
}
)
}
}
},
// テーブルの初期化処理
initialize () {
this.hotels = []
this.plans = []
this.searchVacantHotel(this.hotelNo) // 楽天トラベル空室検索APIのレスポンスを得る
this.setTable(this.hotels)
},
// el-collapseが開いた時に該当する宿泊施設の空室検索を行う
handleChange (value) {
if (value.length === 1) {
this.moreText = 'プラン一覧を閉じる'
if (this.plans.length === 0) {
this.initialize()
}
} else {
this.moreText = 'プラン一覧を見る'
}
},
// 楽天トラベル空室検索API
searchVacantHotel (hotelNo) {
let startDate = this.conditions.visitDuration[0] // 検索条件に指定されたチェックイン日
let endDate = this.conditions.visitDuration[1] // 検索条件に指定されたチェックアウト日
let params = {
applicationId: process.env.RAKUTEN_API_KEY,
format: 'json',
formatVersion: '2',
elements: 'hotels', // 取得する要素をhotelsノードに限定する
datumType: '1',
hotelNo: hotelNo, // 表示中の宿泊施設番号
hits: '3',
adultNum: this.conditions.adultNum, // 検索条件に指定された宿泊人数(大人)
checkinDate: startDate,
checkoutDate: endDate,
responseType: 'middle',
sort: 'standard'
}
const API_PATH = '/Travel/VacantHotelSearch/20170426'
requestRakutenApi(API_PATH, params, hotelNo)
.then(data => {
if (data !== null) {
this.hotels = data.hotels
}
})
}
}
}
</script>
▲HotelPlanList.vue
コンポーネントの見た目は以下のようになっています。ElementUIのCollapseとTableコンポーネントを利用しています。
改めて見ると、空き部屋(プラン)があるかどうか分からないのに、わざわざ利用者に Collapseコンポーネントを開閉を行わせるのは不親切ですね。実装するのに苦労したのになんということだ…
watch: {
// 楽天トラベル空室検索APIのレスポンスを取得した時にテーブルの初期化処理を行う
hotels: function () {
if (this.hotels.length !== 0) {
this.setTable(this.hotels[0])
}
}
},
methods: {
// 検索条件と宿泊施設に紐づくプラン一覧を表形式で表示するための処理
setTable (data) {
// 配列の0〜2の要素は処理の対象外
if (data.length > 3) {
// 楽天トラベル空室検索APIで取得したレスポンスのroomBasicInfoを加工してリストにする
for (let i = 3; i < data.length; i++) {
let roomBasicInfo = data[i].roomInfo[0].roomBasicInfo
let dailyCharge = data[i].roomInfo[1].dailyCharge
this.plans.push(
{
planName: roomBasicInfo.planName,
roomName: roomBasicInfo.roomName,
reserveUrl: roomBasicInfo.reserveUrl,
stayDate: dailyCharge.stayDate,
total: (chargeToLocalStringWithTax(dailyCharge.total) + ' 円')
}
)
}
}
},
この処理で、空き部屋(宿泊プラン)が1件以上あればel-table
のdata
にバインドするためのオブジェクトを作っています。
また、以下のようにel-table-column
の中に<template slot-scope="scope">
を書くことでバインドされたプロパティ(prop)にscope.row.reserveUrl
でアクセス出来るようです。
<el-table-column prop="reserveUrl" label="プラン詳細" width="240">
<template slot-scope="scope">
<el-button @click="visitRakutenTravel(scope.row.reserveUrl)" type="warning" round>
<strong>このプランの詳細を見る</strong>
</el-button>
</template>
</el-table-column>
最後のコンポーネントはページネーションです。
<template>
<div>
<!--
current-page: 現在のページインデックスを指定する
total: 検索可能な宿泊施設件数を入れる(APIの制限で最大1,000件まで)
page-size: 1ページに表示する最大件数を指定する(ここでは10件の固定値)
-->
<el-pagination
background
layout="prev, pager, next"
:current-page="conditions.currentPage"
:total="totalPages"
:page-size="10"
@current-change="handleCurrentChange">
</el-pagination>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
methods: {
// 各ページのリンクがクリックされた時に処理を行う
handleCurrentChange (index) {
this.$store.commit('HotelSearch/UPDATE_CURRENT_PAGE', index) // 現在のページインデックスを更新する
// キーワード検索APIと施設検索APIで処理を分ける
let mode = this.conditions.searchMode
switch (mode) {
case 0:
// 楽天トラベルキーワード検索API
this.$store.dispatch('HotelSearch/keywordHotelSearch')
break
case 1:
// 楽天トラベル施設検索API
this.$store.dispatch('HotelSearch/simpleHotelSearch')
break
default:
console.log('This search mode is not defined. argument: ' + mode)
break
}
}
},
computed: {
...mapGetters('HotelSearch', ['conditions']),
...mapGetters('HotelSearch', ['pagingInfo']),
// 1,000件以降は検索出来ないため、上限値は1,000件までとする
// 例えば、1ページの辺りの件数が10件の場合は、10件 * 100ページまでとなる
totalPages () {
if (this.pagingInfo.recordCount > 1000) {
return 1000
} else {
return this.pagingInfo.recordCount
}
}
}
}
</script>
▲ElementUIのPagenationコンポーネントは、現在のページインデックス値、1ページ辺りの最大件数、最大件数のデータがあれば比較的簡単に動作させることが出来ます。
そして、最後のMainPage.vue
へアクセスするためのルーティングを設定すれば完成です。
import Vue from 'vue'
import Router from 'vue-router'
import MainPage from '@/components/MainPage'
Vue.use(Router)
export default new Router({
routes: [
{
path: '/',
name: 'MainPage',
component: MainPage
}
]
})
一通り実装してみて得られたこと
やはりエンジニアさんの苦労が垣間見られるところだと思います。上記サンプルコードの品質は低く、粗を探せばいくらでも見つかるという感じですし、単体テストも書かれていません。このようなアプリをプロダクション向けの品質を保ちながら、納期に間に合わせて作るのは相当な努力や経験が必要になると思います。
QAはエンジニアさんと協力しながら品質が高く、ユーザーに価値のあるものを提供するというゴールに向かって共に歩む戦友みたいな間柄だと思いますので、お互いへのリスペクトが必要になります。
このような体験によってエンジニアさんのことを少しでも理解出来ることにつながり、またQAに対してもスキルを磨く努力をしているんだなと少しでも思ってもらえればそれは意味のあることだと思います。
ただ、誤算はVue.jsのコーディングが面白く、好奇心が先走りすぎて色々実装してしまいボロが出そうなので、このエムスリーAdvent Calendar 2017の21日目の記事を書き上げたらVue.jsの初歩に戻って技術をものにしたいと思います。
ちなみに、QAなのにどこへ向かっているんだ、と時々言われたりします(笑)
まぁ、世の中広いんだからVue.jsやKotlinが書けるQAが居たっていいじゃない?