- 投稿日:2020-07-05T21:20:54+09:00
JSのオブジェクト型のデータを配列に変換して重複削除とfilter関数を使って欲しい情報のみを抽出する
案件でAPIから受けとったオブジェクト型のデータの重複を取り除いたり欲しい情報のみを抽出するといったことをしました。
結構詰まったのでメモです。<script> export default { data() { return { obj: { '1': { id: 1, name: 'javascript' }, '2': { id: 2, name: 'vue.js'}, '3': { id: 3, name: 'react'}, '4': { id: 4, name: 'angular'}, '5': { id: 5, name: 'vue.js'}, '6': { id: 6, name: 'angular'}, '7': { id: 7, name: 'vue.js'} } } }, }このようなデータがあります。
これを表示させるとこんな感じになります。<div> <div v-for="(js, index) in obj" :key="index"> {{ index }} {{ js.name }} </div> </div>
まあまあ普通にv-forで表示させただけのシンプルな文です。nameが"vue.js"のものだけを表示させる
このデータの"name"が"vue.js"のもののみを抽出させたいと思います。
<template> <div> <div v-for="(js, index) in objInArray" :key="index"> {{ index }} {{ js.name }} </div> </div> </template> <script> export default { data() { return { obj: { '1': { id: 1, name: 'javascript' }, '2': { id: 2, name: 'vue.js'}, '3': { id: 3, name: 'react'}, '4': { id: 4, name: 'angular'}, '5': { id: 5, name: 'vue.js'}, '6': { id: 6, name: 'angular'}, '7': { id: 7, name: 'vue.js'} } } }, computed: { objInArray () { this.objectOperation() return this.obj } }, methods: { objectOperation() { const obj = this.obj const result = obj.filter((value) => { return value.name === 'vue.js' }) this.obj = result } }, }しかしこれはエラーが起きてしまいます。
filter関数を使用しているところが配列ではないためです。
そこでObject.entries()
の出番です。computed: { objInArray () { this.objectOperation() return this.obj } }, methods: { objectOperation() { const arr = Object.entries(this.obj) console.log(arr) const result = arr.filter(([id, value]) => { console.log(id) return value.name === 'vue.js' }) this.obj = result } }1行づつ説明していくと、
Object.entries(this.obj)
でオブジェクト型を配列形式に変換しています。
arr
の中はこのような感じになっています。1つの配列に7つの配列が格納されているのがわかるかと思います。
このような形にすることでfilter関数やらfind関数やらが使えるようになります。
そして先ほど配列形式に変換したarr
をfilter関数を使用してvalue.name
がvue.js
の物のみを抽出しています。filter関数の引数
arr.filter([id, value])
は配列の添字を指しており、これを記述することでid番目のvalueといったように直接指定することができます。
最後にobj
をうわがいてこのスクリプトは終了です。<template> <div> <div v-for="(js, index) in objInArray" :key="index"> {{ index }} {{ js[1].name }} </div> </div> </template>上記のようにHTMLを書いてあげると下記の添付画像のように
vue.js
のみが抽出されました。
{{ js[1].name }}
の[1]
は何かどこから来たのかというと
先ほどのスクリプトで上の画像のような形で配列を受け取ります。
obj
という配列を一件ずつ回して表示させています。
HTML上でvue.js
と表示させるにはobj
の中に複数ある配列の中の1: Object
の中にname: vue.js
があるため{{ js[1].name }}
と書いてあげることで添字を直接指定して表示させています。
- 投稿日:2020-07-05T21:20:54+09:00
JSのオブジェクト型のデータを配列に変換してfilter関数を使って欲しい情報のみを抽出する
案件でAPIから受けとったオブジェクト型のデータの重複を取り除いたり欲しい情報のみを抽出するといったことをしました。
結構詰まったのでメモです。<script> export default { data() { return { obj: { '1': { id: 1, name: 'javascript' }, '2': { id: 2, name: 'vue.js'}, '3': { id: 3, name: 'react'}, '4': { id: 4, name: 'angular'}, '5': { id: 5, name: 'vue.js'}, '6': { id: 6, name: 'angular'}, '7': { id: 7, name: 'vue.js'} } } }, }このようなデータがあります。
これを表示させるとこんな感じになります。<div> <div v-for="(js, index) in obj" :key="index"> {{ index }} {{ js.name }} </div> </div>
まあまあ普通にv-forで表示させただけのシンプルな文です。nameが"vue.js"のものだけを表示させる
このデータの"name"が"vue.js"のもののみを抽出させたいと思います。
<template> <div> <div v-for="(js, index) in objInArray" :key="index"> {{ index }} {{ js.name }} </div> </div> </template> <script> export default { data() { return { obj: { '1': { id: 1, name: 'javascript' }, '2': { id: 2, name: 'vue.js'}, '3': { id: 3, name: 'react'}, '4': { id: 4, name: 'angular'}, '5': { id: 5, name: 'vue.js'}, '6': { id: 6, name: 'angular'}, '7': { id: 7, name: 'vue.js'} } } }, computed: { objInArray () { this.objectOperation() return this.obj } }, methods: { objectOperation() { const obj = this.obj const result = obj.filter((value) => { return value.name === 'vue.js' }) this.obj = result } }, }しかしこれはエラーが起きてしまいます。
filter関数を使用しているところが配列ではないためです。
そこでObject.entries()
の出番です。computed: { objInArray () { this.objectOperation() return this.obj } }, methods: { objectOperation() { const arr = Object.entries(this.obj) console.log(arr) const result = arr.filter(([id, value]) => { console.log(id) return value.name === 'vue.js' }) this.obj = result } }1行づつ説明していくと、
Object.entries(this.obj)
でオブジェクト型を配列形式に変換しています。
arr
の中はこのような感じになっています。1つの配列に7つの配列が格納されているのがわかるかと思います。
このような形にすることでfilter関数やらfind関数やらが使えるようになります。
そして先ほど配列形式に変換したarr
をfilter関数を使用してvalue.name
がvue.js
の物のみを抽出しています。filter関数の引数
arr.filter([id, value])
は配列の添字を指しており、これを記述することでid番目のvalueといったように直接指定することができます。
最後にobj
をうわがいてこのスクリプトは終了です。<template> <div> <div v-for="(js, index) in objInArray" :key="index"> {{ index }} {{ js[1].name }} </div> </div> </template>上記のようにHTMLを書いてあげると下記の添付画像のように
vue.js
のみが抽出されました。
{{ js[1].name }}
の[1]
は何かどこから来たのかというと
先ほどのスクリプトで上の画像のような形で配列を受け取ります。
obj
という配列を一件ずつ回して表示させています。
HTML上でvue.js
と表示させるにはobj
の中に複数ある配列の中の1: Object
の中にname: vue.js
があるため{{ js[1].name }}
と書いてあげることで添字を直接指定して表示させています。
- 投稿日:2020-07-05T20:23:32+09:00
画像と音声ファイルをその場で反映させる方法
Vueでアップロードしたファイルの読み込みをやってみる
sound-reader-vue.html<body> <div id="app"> <section> <label for="sound_path">音声</label> <div> <input type="file" id="sound_path" accept="audio/*" class="form-control-file" name="sound" @input="readSound"> <div> <audio :src="sound" controls> </div> </div> </section> </div> <script src="https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.js"></script> <script> var app = new Vue({ el: '#app', data() { return { soundSrc: '', } }, computed: { sound(){ return this.soundSrc; } }, methods: { readSound(event){ var reader = new FileReader(); var file = event.target.files[0]; let that = this;//忘れないように reader.onload = function (event) { that.soundSrc = event.target.result;//読み込んだ結果をsoundSrcに }; reader.readAsDataURL(file);//ファイルをURIに変換し読み込む。ちなみにこの処理はonloadの前でも可能 }, }, }) </script> </body>@inputイベントはinputにファイルがアップロードまたは変更された時に発生する。
今回はreadSound()関数が呼び出されている。
event.target.filesは配列なので[]が必要であることを忘れない。
var files = event.target.filesというパターンもある。
関数内の関数のため、thatまたはselfに変える。
onloadはreader.readAsDataURL(file)の処理が終わった後に行う処理である。ちなみに
blob の result は、先に Base64 でエンコードされたデータの前にある Data-URL の宣言を削除しておかないと、直接 Base64 としてデコードすることができません。
とのこと
https://developer.mozilla.org/ja/docs/Web/API/FileReader/readAsDataURL
- 投稿日:2020-07-05T20:23:32+09:00
音声ファイルをその場で反映させる方法
Vueでアップロードしたファイルの読み込みをやってみる
sound-reader-vue.html<body> <div id="app"> <section> <label for="sound_path">音声</label> <div> <input type="file" id="sound_path" accept="audio/*" class="form-control-file" name="sound" @input="readSound"> <div> <audio :src="sound" controls> </div> </div> </section> </div> <script src="https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.js"></script> <script> var app = new Vue({ el: '#app', data() { return { soundSrc: '', } }, computed: { sound(){ return this.soundSrc; } }, methods: { readSound(event){ var reader = new FileReader(); var file = event.target.files[0]; let that = this;//忘れないように reader.onload = function (event) { that.soundSrc = event.target.result;//読み込んだ結果をsoundSrcに }; reader.readAsDataURL(file);//ファイルをURIに変換し読み込む。ちなみにこの処理はonloadの前でも可能 }, }, }) </script> </body>@inputイベントはinputにファイルがアップロードまたは変更された時に発生する。
今回はreadSound()関数が呼び出されている。
event.target.filesは配列なので[]が必要であることを忘れない。
var files = event.target.filesというパターンもある。
関数内の関数のため、thatまたはselfに変える。
onloadはreader.readAsDataURL(file)の処理が終わった後に行う処理である。ちなみに
blob の result は、先に Base64 でエンコードされたデータの前にある Data-URL の宣言を削除しておかないと、直接 Base64 としてデコードすることができません。
とのこと
https://developer.mozilla.org/ja/docs/Web/API/FileReader/readAsDataURL
- 投稿日:2020-07-05T20:01:35+09:00
パスワードの強さを判定
過去にjQueryでパスワードの強さを判定する記事があり、これをVue.jsでやってみた。
strength.html<body> <div id="app"> <section> <input type="text" v-model="password"> {{ judge }} </section> </div> <script src="https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.js"></script> <script> var app = new Vue({ el: '#app', data() { return { password: '', } }, computed: { judge(){ var strength = 0 //強さ var password = this.password; // 文字数が7より大きいければ+1 if (password.length > 7) strength += 1 // 英字の大文字と小文字を含んでいれば+1 if (password.match(/([a-z].*[A-Z])|([A-Z].*[a-z])/)) strength += 1 // 英字と数字を含んでいれば+1 if (password.match(/([a-zA-Z])/) && password.match(/([0-9])/)) strength += 1 // 記号を含んでいれば+1 if (password.match(/([!,%,&,@,#,$,^,*,?,_,~])/)) strength += 1 // 記号を2つ含んでいれば+1 if (password.match(/(.*[!,%,&,@,#,$,^,*,?,_,~].*[!,%,&,@,#,$,^,*,?,_,~])/)) strength += 1 if (password.length < 6) { return '短すぎる!!' } // 点数を元に強さを計測 if (strength < 2) { return '弱い' } else if (strength == 2) { return '普通' } else { return '強い' } } }, }) </script> </body>v-modelでinput内の変化を検知し、それをcomputedで計算する。
序盤のif文に該当する条件があればスコアが(変数strengthに)加算されていき、スコア次第で後半のif文が判定する仕様。
- 投稿日:2020-07-05T18:27:50+09:00
AWS Amplify(Cognito)でLINEへソーシャルログインする
Amplify
は、高速でアプリを開発できる開発プラットフォームです。
Amplify
で作成したVue.js
アプリでLINE
とのログイン連携を試したので、メモしておきます。Googleとのログイン連携はこちら
作成したもの
LINEアカウントの入力部分は動画編集でトリム(削除)しています。
ソースコードの全量はGitHubにあります。
https://github.com/Thirosue/amplify-sns-fedaration前提
Amplify
の設定が完了していること(amplify configure
、amplify init
およびamplify push
が済んでいること)Amplify
で作成したVue.js
アプリが存在すること(aws-amplify
とaws-amplify-vue
の導入・設定が済んでいること)
Amplify
の状態は以下のとおり(amplify status
)$ amplify status Current Environment: prod | Category | Resource name | Operation | Provider plugin | | -------- | ------------- | --------- | --------------- |
Amplify
のバージョンはv4
系% amplify -v 4.21.3
Amplifyの設定はこちらを参照してください。
AWS 怠惰なプログラマ向けお手軽アプリ開発手法 2019
https://feature-webpush.dma9ecr5ksxts.amplifyapp.com/手順
LINE
とのログイン連携の手順は以下のとおりです。
- LINE側(
LINE Developers
)の設定- 認証モジュールの追加(
amplify add auth
)Congnito
のLINE連携(OpenID Connect
)設定- LINE側(
LINE Developers
)にリダイレクトURLの設定1. LINE側(
LINE Developers
)の設定Step1. LINE Developers ( https://developers.line.biz/ja/ ) にログインします
Step2. プロバイダーを作成します。
プロバイダーを作成します。
Step3. チャンネルを作成します。
少しわかりづらいですが、「Create a LINE Login channel」を選択し、チャンネルを作成します。
チャンネル名やメールアドレスなどの必須項目を入力し、作成します。
作成したチャンネルの「チャンネル ID(Channel ID)」と「チャンネル シークレット(Channel secret)」は
3. Congnito のLINE連携(OpenID Connect)設定
で利用するので、メモしておきましょう。2. 認証モジュールの追加(
amplify add auth
)認証機能をコマンドライン(
Amplify CLI
)で追加していきます。
以下コマンドで認証モジュール(Cognito
)を追加します。amplify add authStep1. Do you want to use the default authentication and security configuration? (Use arrow keys)
Default configuration with Social Provider (Federation)
を選択Do you want to use the default authentication and security configuration? Default configuration ❯ Default configuration with Social Provider (Federation) Manual configuration I want to learn more.Step2. How do you want users to be able to sign in? (Use arrow keys)
どれでもいいですが、
Username
を選択How do you want users to be able to sign in? (Use arrow keys) ❯ Username Email Phone Number Email or Phone Number I want to learn more.Step3. Do you want to configure advanced settings? (Use arrow keys)
Yes, I want to make some additional changes.
を選択Do you want to configure advanced settings? No, I am done. ❯ Yes, I want to make some additional changes.Step4. What attributes are required for signing up?
Name
を有効にするWhat attributes are required for signing up? ◯ Gender (This attribute is not supported by Login With Amazon.) ◯ Locale (This attribute is not supported by Facebook, Google.) ◯ Given Name (This attribute is not supported by Login With Amazon.) ❯◉ Name ◯ Nickname (This attribute is not supported by Facebook, Google, Login With Amazon.) ◯ Phone Number (This attribute is not supported by Facebook, Login With Amazon.) ◯ Preferred Username (This attribute is not supported by Facebook, Google, Login With Amazon.)選択結果は以下のとおり
What attributes are required for signing up? Name
Step5.
Do you want to enable any of the following capabilities? (Press <space> to select, <a> to toggle all, <i> to invert selection)
デフォルト(未選択)のまま進む
Step6. What domain name prefix do you want to use?
ドメインの接頭辞はデフォルトのまま進む(※デフォルトの値が設定されていることを確認して進む)
What domain name prefix do you want to use? amplifysnsfedarationyyyyyyy-xxxxxxx
Step7. Enter your redirect signin URI:
ローカルで試すので、
http://localhost:8080/
を入力Enter your redirect signin URI: http://localhost:8080/Step8. Do you want to add another redirect signin URI (y/N)
ローカルで試すので、その他のリダイレクトURLは不要
Do you want to add another redirect signin URI (y/N) NStep9. Enter your redirect signout URI:
ローカルで試すので、
http://localhost:8080/
を入力Enter your redirect signout URI: http://localhost:8080/Step10. ? Do you want to add another redirect signout URI (y/N)
ローカルで試すので、その他のサインアウトURLは不要
? Do you want to add another redirect signout URI (y/N) NStep11.
Select the social providers you want to configure for your user pool: (Press <space> to select, <a> to toggle all, <i> to invert selection)
LINE
(OpenID Connect
)はAmplify CLI
でサポートされていない(version 4.21.3
)ので、デフォルトのまま進む。Select the social providers you want to configure for your user pool:
Step12. 設定の反映(AWSリソースの作成)
以下コマンドで設定を反映(AWSリソースの作成)します。
amplify push実行結果
正常に認証モジュールが追加されていることを以下コマンド(
amplify status
)で確認します。$ amplify status Current Environment: prod | Category | Resource name | Operation | Provider plugin | | -------- | ---------------------------- | --------- | ----------------- | | Auth | amplifysnsfedarationXXXXXXXX | No Change | awscloudformation |設定の全量
% amplify add auth Using service: Cognito, provided by: awscloudformation The current configured provider is Amazon Cognito. Do you want to use the default authentication and security configuration? Default configuration with Social Provider (Federation) Warning: you will not be able to edit these selections. How do you want users to be able to sign in? Username Do you want to configure advanced settings? Yes, I want to make some additional changes. Warning: you will not be able to edit these selections. What attributes are required for signing up? Name Do you want to enable any of the following capabilities? What domain name prefix do you want to use? amplifysnsfedarationyyyyyyy-xxxxxxx Enter your redirect signin URI: http://localhost:8080/ ? Do you want to add another redirect signin URI No Enter your redirect signout URI: http://localhost:8080/ ? Do you want to add another redirect signout URI No Select the social providers you want to configure for your user pool: Successfully added resource amplifysnsfedarationdff223d9 locally Some next steps: "amplify push" will build all your local backend resources and provision it in the cloud "amplify publish" will build all your local backend and frontend resources (if you have hosting category added) and provision it in the cloud hirosue@PC876 amplify-sns-fedaration % amplify push ✔ Successfully pulled backend environment prod from the cloud.3.
Congnito
のLINE連携(OpenID Connect
)設定Step1.
AWS
にログインして、Cognito
のサービスページに移動します。Step2. ユーザプールを選択します。
「ユーザープールの管理」を選択し、
2. 認証モジュールの追加
で作成した「ユーザープール」を選択します。対象の「ユーザープール」の名称は、
Amplify
の状態(amplify status
)で確認したリソース名(Resource name
)で始まります。Step3. 「ID プロバイダー」(
OpenID Connect
)を追加します。図のとおり入力します。
項目名 値 プロバイダ名 LINE
(任意の値)クライアントID ( 1. LINE側(LINE Developers)の設定
で設定した「チャンネル ID(Channel ID)」)クライアントのシークレット(オプション) ( 1. LINE側(
LINE Developers)の設定
で「チャンネル シークレット(Channel secret)」属性のリクエストメソッド GET
(デフォルトのまま)承認スコープ profile mail openid
発行者 https://access.line.me
識別子(オプション) - 認証エンドポイント https://access.line.me/oauth2/v2.1/authorize
トークンエンドポイント https://api.line.me/oauth2/v2.1/token
ユーザー情報エンドポイント https://api.line.me/v2/profile
Jwks uri https://api.line.me/oauth2/v2.1/verify
- (参考リンク)LINEとの連携設定
Step4. Step3で作成した「ID プロバイダー」(
OpenID Connect
)の属性マッピングを変更します。
2. 認証モジュールの追加
で作成した「ユーザープール」はName
属性が必須であるため、sub
⇆Name
のマッピングを追加します。
- (参考)「ユーザープール」の設定
What attributes are required for signing up? Name
Step5. アプリクライアントの設定で
LINE
ログインを有効化します。作成されている2つのアプリクライアント(
xxxxxxxxxxxxxx_app_client
およびxxxxxxxxxxxxxx_app_clientWeb
)に対し、LINE
連携を有効化します(チェックして保存する)。4. LINE側(
LINE Developers
)にリダイレクトURLの設定
1. LINE側(LINE Developers)の設定
で作成した「プロバイダー」にCognito
で作成された認証のリダイレクトURLを設定します。Step1.
Amplify
の設定ファイル(aws-exports.js
)を確認して、Cognito
のドメインを確認します。
2. 認証モジュールの追加
の結果作成された、Amplify
の設定ファイル(aws-exports.js
)のCognito
のドメインの箇所を確認します。% cat src/aws-exports.js | grep domain "domain": "amplifysnsfedarationyyyyyyy-xxxxxxx-prod.auth.ap-northeast-1.amazoncognito.com",Step2.
1. LINE側(LINE Developers)の設定
で作成した「プロバイダー」にCognito
で作成された認証のリダイレクトURLを設定します。「TOP」-「Cognito Test」-「Cognito Test」-「LINE Login」と移動して、リダイレクトURL(
CallBack URL
)を入力します。設定する値は
https://(Step1で確認したドメイン)/oauth2/idpresponse
です。
上の例の場合は、https://amplifysnsfedarationyyyyyyy-xxxxxxx-prod.auth.ap-northeast-1.amazoncognito.com/oauth2/idpresponse
となります。アプリケーションの修正
App.vue
を修正します。ログインに付随する細かい処理(ダイアログ出力、状態の管理(ローディング、サインイン)など)の説明は割愛します。
詳細は、全量のソースコード(GitHub)を確認してください。
https://github.com/Thirosue/amplify-sns-fedaration
html
の修正「LINEでログイン」のリンクを追加
<a href="#" @click="signIn('LINE')">LINEでログイン</a>サインインで画面切り替え
サインイン状態(signedIn)で表示を切り替えます。
<!-- サインイン状態はstoreかdataに設定する --> <template v-if="signedIn"> <h1>Logged in</h1> <a href="#" @click="signOut"><font color="gray">Sign Out</font></a> </template> <template v-else> <amplify-authenticator v-bind:authConfig="authConfig" /> <ul> <li><a href="#" @click="signIn('LINE')">LINEでログイン</a></li> </ul> </template>
method
の追加ログイン処理を追加します。
Vue.jsimport { Auth, Hub } from "aws-amplify"; //(...中略...) methods: { async signIn(provider) { this.$store.dispatch("loading", true); //処理中表示(処理開始) const res = await Auth.federatedSignIn({ provider }); console.log(res); },イベントフックを追加
Vue.jsimport { Auth, Hub } from "aws-amplify"; //(...中略...) async beforeCreate() { //サインインイベントフックを追加 Hub.listen("auth", async (data) => { switch (data.payload.event) { case "signIn": { // サインインイベントをフック const cognitoUser = await Auth.currentAuthenticatedUser(); console.log(`signed in ... ${cognitoUser.username}`); this.$store.dispatch("signedIn", true); this.$store.dispatch("loading", false); //処理中表示(処理終了) Swal.fire({ // ダイアログ表示 position: "top-end", icon: "success", title: "ログインしました", showConfirmButton: false, timer: 1500, }); break; } default: break; } }); },確認
アプリケーションの起動
以下コマンド(
yarn serve
)でアプリケーションを起動しますyarn servehttp://localhost:8080/へアクセスして確認します。
「LINEでログイン」リンクをクリックすると、LINEへログインした後、ログイン後状態に遷移します。
参考リンク
- 投稿日:2020-07-05T18:16:40+09:00
【GAS x Vue.js】JavaScript のみで今、家計簿をつくるとしたら【ハンズオン付き!】
「JavaScriptのみ」&「無料」&「サーバーレス」なスプレッドシートと連携した家計簿をつくる方法を考えてみました。
実際に家計簿アプリを作るハンズオン付きです!なにを作ったの?
Web上でデータを登録すると、スプレッドシートに反映される家計簿アプリです。
実際のページはこちら。使い方は「家計簿アプリお試し方法」で説明します。
スプレッドシートは月ごとにシートで管理され、Webアプリと同じように収支の合計も確認できます。
使用した技術
- バックエンド
- Google Apps Script (GAS)
- フロントエンド
- Vue.js / Vue Router / Vuex
- Vuetify
- axios
制作のポイント
GAS で REST API もどきを作った
GAS で受け付けることのできるリクエストは
GET
とPOST
の2種類だけです。(doGet
,doPost
関数)
これでは REST API を作ることはできないので、
リクエスト内容にメソッドの文字列を入れることで擬似的にGET
,POST
,PUT
,DELETE
に対応させました!
家計簿は月ごとにシートを分けた
メリット
- 指定年月のデータ取得時の実行コストが低くなる
- データ数が増えても API が重くなりにくい
- スプレッドシートの内容を確認しやすい
指定年月のシートのデータをすべて取得すればいいので、「データが指定年月のものであるか?」を確認する必要がなくなります。
そのため、データ数が多くなっても1枚のシートで管理するより重くなりにくいです。また、Webアプリ/スプレッドシートどちらからでも家計簿のデータを確認しやすいのが強みです。
デメリット
- データ年月の編集時の実行コストが高くなる
- 月をまたいだデータの取得/集計などが困難になる
編集前後でデータの年月を変えると、
「編集前の年月シートから削除」→「編集後の年月シートに追加」
する必要があるので、コストが高くなってしまいます。(そんな編集をすることは滅多にないと思いますが…)また、今回作った API の仕様だと、1年分のデータを取得するのに、12回 API を叩く必要があります。
Webアプリでは月ごとの表示しかしていませんが、より細かい集計などするには API の改修が必要そうです。家計簿アプリお試し方法
それでは、実際にこのアプリを試してみる方法を紹介します。
3ステップだけで完了します!STEP 1:シート準備
Google スプレッドシートで新しいシートを作成して、「ツール」タブ→「スクリプトエディタ」をクリックします。
もし表示されていなかった場合は、「実行」タブから V8 ランタイムを有効にします。
コード.gs
にこのプログラムをコピペして保存します。プロジェクト名は好きな名前でOKです。
STEP 2:API URL の発行
「公開」タブ→「ウェブ アプリケーションとして導入」をクリックします。
「Project version」は「New」、
「Execute the app as」は「Me (自分のメールアドレス)」、
「Who has access to the app」は「Anyone, even anonymous」
で「Deploy」ボタンをクリックします。
「Authorization Required」というダイアログが表示されるので、
「許可を確認」ボタンをクリックしたあと、スプレッドシートを作成したアカウントでログインして「許可」ボタンをクリックします。
「Deploy as web app」というダイアログが表示されれば、準備完了です。
「Current web app URL」の内容をコピーしておきます。
※実際の URL はもっと長いです。
この URL は誰でもアクセスができてしまうので、一応
authToken
を設定できます。(URL を他人に知られることはないと思いますが)※この設定は任意です
「ファイル」タブ→「プロジェクトのプロパティ」→「スクリプトのプロパティ」を開きます。
authToken
という行を追加して、UUID v4 などの値を設定します。
STEP 3:アプリ設定
家計簿アプリの設定を開き、「API URL」と「Auth Token」を入力して、「保存」ボタンをクリック。
※ STEP 2 でauthToken
を設定してない方は空のままでOKです。
右上にあるシートマークのボタンをクリックします。
← このマークエラーが表示されなければ準備完了です!
実際に家計簿データを入力して、スプレッドシートに反映されるか試してみてください!アプリを作ってみる!
おまたせしました!ここからハンズオンになります!
対象は JavaScript / Vue.js 初心者~中級者向けです。ハンズオンは以下の3部構成でお送りします!
- Vue.js / Vue Router / Vuex でフロント実装してみる
- Google Apps Script で REST API もどきを作ってみる
- 作った API と axios で実際に通信してみる
内容結構長いので、記事の最後まで飛びたい方はこちらをクリック。
環境構築
開発環境
Node.js と Yarn がインストールされている前提で進めます。
下記のバージョンと近いものか、高いものであれば基本動くと思います。> node -v v12.16.3 > yarn -v v1.22.4Vue CLI 4 のインストール
Vue.js アプリを簡単につくることができるようになる
Vue CLI
をインストールします。
執筆時点の最新バージョンは4.4.5
でした。> yarn global add @vue/cli > vue --version @vue/cli 4.4.5プロジェクトの作成
vue create 好きなアプリ名
と打つと、プロジェクトを作成できます。
実行例では、アプリ名をgas-account-book
として進めます。> vue create gas-account-bookデフォルトを選択すると一発でプロジェクトを作成できますが、
今回Vue Router
とVuex
を追加したいので、マニュアルで進めます。
(上下でカーソル移動、エンターで決定できます)Vue CLI v4.4.5 ? Please pick a preset: default (babel, eslint) > Manually select features「Babel」「Linter / Formatter」の選択はそのままで、
「Router」「Vuex」を追加して決定します。
(スペースキーで選択の状態を切り替えられます)? Check the features needed for your project: >(*) Babel ( ) TypeScript ( ) Progressive Web App (PWA) Support (*) Router (*) Vuex ( ) CSS Pre-processors (*) Linter / Formatter ( ) Unit Testing ( ) E2E Testing
history mode
を使うか?と尋ねられますが、今回は使わないので「n」を入力します。? Use history mode for router? (Requires proper server setup for index fallback in production) (Y/n) nESLint の設定はエラー防止のみの「ESLint with error prevention only」を選択します。
? Pick a linter / formatter config: > ESLint with error prevention only ESLint + Airbnb config ESLint + Standard config ESLint + Prettier保存のときに Lint してもらいたいので、「Lint on save」のまま次へ。
? Pick additional lint features: >(*) Lint on save ( ) Lint and fix on commit設定ファイルは config ファイルに書いてほしいので、
「In dedicated config files」を選択。? Where do you prefer placing config for Babel, ESLint, etc.? > In dedicated config files In package.json今回のプロジェクト設定を保存するか聞かれますが、「N」で次へ。
? Save this as a preset for future projects? (y/N) N必要パッケージのインストールがはじまります。
Vue CLI v4.4.5 ✨ Creating project in /xxxxx/gas-account-book. ?️ Initializing git repository... ⚙️ Installing CLI plugins. This might take a while...このように表示されれば完了です。
? Successfully created project gas-account-book. ? Get started with the following commands: $ cd gas-account-book $ yarn serve
gas-account-book
ディレクトリ内に移動します。> cd gas-account-book次に
Vuetify
を追加します。
Vue CLI
を使うと、簡単にプラグインもインストールできます!ちなみに
Vuetify
とは Vue 用のマテリアルデザインフレームワークです。
今回はデザインをVuetify
まかせにしてサボります。このハンズオンに出てくる
v-
から始まるタグはすべてVuetify
のコンポーネントです。
デザイン面の話はあまり触れないので、気になる方は公式ドキュメントを参照してください。> vue add vuetifyこの設定はデフォルトで進めます。
✔ Successfully installed plugin: vue-cli-plugin-vuetify ? Choose a preset: > Default (recommended) Prototype (rapid development) Configure (advanced)このように表示されれば完了です。
✔ Successfully invoked generator for plugin: vue-cli-plugin-vuetify vuetify Discord community: https://community.vuetifyjs.com vuetify Github: https://github.com/vuetifyjs/vuetify vuetify Support Vuetify: https://github.com/sponsors/johnleider
yarn serve
コマンドで開発サーバーを起動してみます。> yarn serve
localhost:8080
ブラウザーでアクセスして、
「Welcome to Vuetify」が表示されれば環境構築完了です!この開発サーバーでは ホットリロード が有効なので、ファイル編集がすぐに反映されます。
以降はこのサーバーが起動している前提で進めて行きます。現時点のソースコード一覧はこちらから確認できます!
Vue.js / Vue Router / Vuex でフロント実装してみる
ようやく環境構築が終わりました。
はじめに、ディレクトリ構成について軽く把握しておきましょう。
ざっとこんな感じになっています。src/ assets/ ...... ロゴなどのアセット components/ .. 主に再利用する vue コンポーネント plugins/ ..... vuetify などのプラグイン router/ ...... ルーティングの設定 store/ ....... Vuexストアの設定 views/ ....... ページを構成する vue ファイル App.vue ...... Vueアプリのメインファイル main.js ...... エントリポイントとなるファイルApp.vue を書き換えてみる
さっそくですが、メインファイルである
App.vue
が自動生成された状態のままなので、
不要なものを消してシンプルにします。App.vue<template> <v-app> <!-- ツールバー --> <v-app-bar app color="green" dark> <!-- タイトル --> <v-toolbar-title>GAS 家計簿</v-toolbar-title> <v-spacer></v-spacer> <!-- テーブルアイコンのボタン --> <v-btn icon to="/"> <v-icon>mdi-file-table-outline</v-icon> </v-btn> <!-- 歯車アイコンのボタン --> <v-btn icon to="/settings"> <v-icon>mdi-cog</v-icon> </v-btn> </v-app-bar> <!-- メインコンテンツ --> <v-main> <v-container fluid> <!-- router-view の中身がパスによって切り替わる --> <router-view></router-view> </v-container> </v-main> </v-app> </template> <script> export default { name: 'App' } </script>ツールバーに表示されたボタンを押すと画面が切り替わると思います。
これは、v-btn
にto
属性を設定すると、ボタンが押されたときにそのパスへ移動できるからです。また、
v-icon
で Material Design Icons が使えます。
使い方はmdi-アイコン名
をv-icon
の中身に書くだけです。App.vue|9-14行目<!-- テーブルアイコンのボタン --> <v-btn icon to="/"> <!-- クリックで "/" へ移動する --> <v-icon>mdi-file-table-outline</v-icon> </v-btn> <!-- 歯車アイコンのボタン --> <v-btn icon to="/settings"> <!-- クリックで "/settings" へ移動する --> <v-icon>mdi-cog</v-icon> </v-btn>URL のパスによって、この
router-view
の中身が切り替わります。
/
は最初に表示されていた画面(Welcome to Vuetify)、
/settings
はまだ作っていないので、何もない画面に切り替わります。App.vue|20-21行目<!-- router-view の中身がパスによって切り替わる --> <router-view></router-view>ルーティングの設定は
src/router/index.js
に書かれています。
このファイルを見てみましょう。router/index.js|7-12行目const routes = [ { path: '/', // パスが "/" のときの設定 name: 'Home', // このルートに "Home" という名前をつける component: Home // router-view の中に Home コンポーネントを表示する },この
Home コンポーネント
は、3行目で読み込まれています。
/
ではsrc/views/Home.vue
を表示しているようですね!router/index.js|3行目import Home from '../views/Home.vue'ここまでの大雑把な流れは、
App.vue -> router -> views
ということがわかりました!ページの中身を書き換えてみる
では、ページを中身を書き換えてみます。
ついでにviews
ディレクトリの中にSettings.vue
も作りましょう。
どちらも中身はシンプルにします。Home.vue<template> <div> <h1>Home コンポーネントだよ</h1> </div> </template> <script> export default { name: 'Home' } </script>Settings.vue<template> <div> <h1>Settings コンポーネントだよ</h1> </div> </template> <script> export default { name: 'Settings' } </script>ルーティングの設定を変えて、
Home
とSettings
が表示されるようにします。router/index.jsimport Vue from 'vue' import VueRouter from 'vue-router' import Home from '../views/Home.vue' import Settings from '../views/Settings.vue' Vue.use(VueRouter) const routes = [ { path: '/', name: 'Home', component: Home }, { path: '/settings', name: 'Settings', component: Settings } ] const router = new VueRouter({ routes }) export default routerホームの画面だけ実装してみる
それでは、ホームの画面だけ実装していきましょう。
月選択フォーム、データ追加ボタン、検索フォーム、テーブル
の4つを作っていきます。Home.vue<template> <div> <v-card> <v-card-title> <!-- 月選択 --> <v-col cols="8"> <v-menu ref="menu" v-model="menu" :close-on-content-click="false" :return-value.sync="yearMonth" transition="scale-transition" offset-y max-width="290px" min-width="290px" > <template v-slot:activator="{ on }"> <v-text-field v-model="yearMonth" prepend-icon="mdi-calendar" readonly v-on="on" hide-details /> </template> <v-date-picker v-model="yearMonth" type="month" color="green" locale="ja-jp" no-title scrollable > <v-spacer/> <v-btn text color="grey" @click="menu = false">キャンセル</v-btn> <v-btn text color="primary" @click="$refs.menu.save(yearMonth)">選択</v-btn> </v-date-picker> </v-menu> </v-col> <v-spacer/> <!-- 追加ボタン --> <v-col class="text-right" cols="4"> <v-btn dark color="green"> <v-icon>mdi-plus</v-icon> </v-btn> </v-col> <!-- 検索フォーム --> <v-col cols="12"> <v-text-field v-model="search" append-icon="mdi-magnify" label="Search" single-line hide-details /> </v-col> </v-card-title> <!-- テーブル --> <v-data-table class="text-no-wrap" :headers="tableHeaders" :items="tableData" :search="search" :footer-props="footerProps" :loading="loading" :sort-by="'date'" :sort-desc="true" :items-per-page="30" mobile-breakpoint="0" > </v-data-table> </v-card> </div> </template> <script> export default { name: 'Home', data () { const today = new Date() const year = today.getFullYear() const month = ('0' + (today.getMonth() + 1)).slice(-2) return { /** ローディング状態 */ loading: false, /** 月選択メニューの状態 */ menu: false, /** 検索文字 */ search: '', /** 選択年月 */ yearMonth: `${year}-${month}`, /** テーブルに表示させるデータ */ tableData: [ /** サンプルデータ */ { id: 'a34109ed', date: '2020-06-01', title: '支出サンプル', category: '買い物', tags: 'タグ1', income: null, outgo: 2000, memo: 'メモ' }, { id: '7c8fa764', date: '2020-06-02', title: '収入サンプル', category: '給料', tags:'タグ1,タグ2', income: 2000, outgo: null, memo: 'メモ' } ] } }, computed: { /** テーブルのヘッダー設定 */ tableHeaders () { return [ { text: '日付', value: 'date', align: 'end' }, { text: 'タイトル', value: 'title', sortable: false }, { text: 'カテゴリ', value: 'category', sortable: false }, { text: 'タグ', value: 'tags', sortable: false }, { text: '収入', value: 'income', align: 'end' }, { text: '支出', value: 'outgo', align: 'end' }, { text: 'メモ', value: 'memo', sortable: false }, { text: '操作', value: 'actions', sortable: false } ] }, /** テーブルのフッター設定 */ footerProps () { return { itemsPerPageText: '', itemsPerPageOptions: [] } } } } </script>…いきなり長いコードになってしまいました。
重要だと思うところを説明します。検索フォームでは
v-model
を使って入力されたデータを同期させています。
この場合はthis.search
で入力された内容を読み取ることができます。Home.vue|47-56行目<!-- 検索フォーム --> <v-col cols="12"> <v-text-field v-model="search" 入力したデータを this.search と同期 append-icon="mdi-magnify" 検索アイコン label="Search" ラベル名 single-line 1行だけ入力できる hide-details 文字カウントなどを非表示 /> </v-col>テーブルにはさまざまなプロパティを設定できます。
今回設定したものはこんな感じです。Home.vue|58-70行目<!-- テーブル --> <v-data-table class="text-no-wrap" 文字を折り返さないようにするクラス :headers="tableHeaders" ヘッダー設定 :items="tableData" テーブルに表示するデータ :search="search" 検索する文字 :footer-props="footerProps" フッター設定 :loading="loading" ローディング状態 :sort-by="'date'" ソート初期設定(列名) :sort-desc="true" ソート初期設定(降順) :items-per-page="30" テーブルに最大何件表示するか mobile-breakpoint="0" モバイル表示にさせる画面サイズ(今回はモバイル表示にさせたくないので 0 を設定) >
headers
にヘッダーの設定、items
に表示するデータを入れるという感じです。
ヘッダーの設定の中身をみてみます。
text
には表示させる列名、value
には表示させるデータのキーを設定します。
たとえば、{ text: '日付', value: 'date' }
は
「日付
列にはデータのdate
を表示する」という設定になります。
また、align
でテキストの寄せる方向、sortable
でソート可否を設定できます。views/Home.vue|104-116行目/** テーブルのヘッダー設定 */ tableHeaders () { return [ { text: '日付', value: 'date', align: 'end' }, { text: 'タイトル', value: 'title', sortable: false }, { text: 'カテゴリ', value: 'category', sortable: false }, { text: 'タグ', value: 'tags', sortable: false }, { text: '収入', value: 'income', align: 'end' }, { text: '支出', value: 'outgo', align: 'end' }, { text: 'メモ', value: 'memo', sortable: false }, { text: '操作', value: 'actions', sortable: false } ] },一応サンプルデータが表示されていますが、
日付やタグの表示、収支を3桁区切りにしたいですよね。
次にこれを実装します。
~ 省略 ~
の部分に変更はありません。Home.vue<!-- ~ 省略 ~ --> <!-- テーブル --> <v-data-table ~ 省略 ~ > <!-- 日付列 --> <template v-slot:item.date="{ item }"> {{ parseInt(item.date.slice(-2)) + '日' }} </template> <!-- タグ列 --> <template v-slot:item.tags="{ item }"> <div v-if="item.tags"> <v-chip class="mr-2" v-for="(tag, i) in item.tags.split(',')" :key="i" > {{ tag }} </v-chip> </div> </template> <!-- 収入列 --> <template v-slot:item.income="{ item }"> {{ separate(item.income) }} </template> <!-- タグ列 --> <template v-slot:item.outgo="{ item }"> {{ separate(item.outgo) }} </template> <!-- 操作列 --> <template v-slot:item.actions="{}"> <v-icon class="mr-2">mdi-pencil</v-icon> <v-icon>mdi-delete</v-icon> </template> </v-data-table> <!-- ~ 省略 ~ -->views/Home.vue/** ~ 省略 ~ */ <script> export default { name: 'Home', data () { /** ~ 省略 ~ */ }, computed: { /** ~ 省略 ~ */ }, methods: { /** * 数字を3桁区切りにして返します。 * 受け取った数が null のときは null を返します。 */ separate (num) { return num !== null ? num.toString().replace(/(\d)(?=(\d{3})+$)/g, '$1,') : null } } } </script>これは Vuetify の決まりごとになってしまいますが、
v-data-table
内のtemplate
でv-slot:item.列名="{ item }"
とすると、その列のデータを加工できます。<!-- 日付列 --> <template v-slot:item.date="{ item }"> <!-- この中で、日付は item.date でアクセスできる --> <!-- '2020-06-01' → '1日' に加工 --> {{ parseInt(item.date.slice(-2)) + '日' }} </template>現時点のソースコード一覧はこちらから確認できます!
操作ダイアログを作る
データを追加/編集するダイアログを作ります。
新しくcomponents
ディレクトリの中にItemDialog.vue
を作成します。ItemDialog.vue<template> <!-- データ追加/編集ダイアログ --> <v-dialog v-model="show" scrollable persistent max-width="500px" eager > <v-card> <v-card-title>{{ titleText }}</v-card-title> <v-divider/> <v-card-text> <v-form ref="form" v-model="valid"> <!-- 日付選択 --> <v-menu ref="menu" v-model="menu" :close-on-content-click="false" :return-value.sync="date" transition="scale-transition" offset-y max-width="290px" min-width="290px" > <template v-slot:activator="{ on }"> <v-text-field v-model="date" prepend-icon="mdi-calendar" readonly v-on="on" hide-details /> </template> <v-date-picker v-model="date" color="green" locale="ja-jp" :day-format="date => new Date(date).getDate()" no-title scrollable > <v-spacer/> <v-btn text color="grey" @click="menu = false">キャンセル</v-btn> <v-btn text color="primary" @click="$refs.menu.save(date)">選択</v-btn> </v-date-picker> </v-menu> <!-- タイトル --> <v-text-field label="タイトル" v-model.trim="title" :counter="20" :rules="titleRules" /> <!-- 収支 --> <v-radio-group row v-model="inout" hide-details @change="onChangeInout" > <v-radio label="収入" value="income"/> <v-radio label="支出" value="outgo"/> </v-radio-group> <!-- カテゴリ --> <v-select label="カテゴリ" v-model="category" :items="categoryItems" hide-details /> <!-- タグ --> <v-select label="タグ" v-model="tags" :items="tagItems" multiple chips :rules="[tagRule]" /> <!-- 金額 --> <v-text-field label="金額" v-model.number="amount" prefix="¥" pattern="[0-9]*" :rules="amountRules" /> <!-- メモ --> <v-text-field label="メモ" v-model="memo" :counter="50" :rules="[memoRule]" /> </v-form> </v-card-text> <v-divider/> <v-card-actions> <v-spacer/> <v-btn color="grey darken-1" text :disabled="loading" @click="onClickClose" > キャンセル </v-btn> <v-btn color="blue darken-1" text :disabled="!valid" :loading="loading" @click="onClickAction" > {{ actionText }} </v-btn> </v-card-actions> </v-card> </v-dialog> </template> <script> export default { name: 'ItemDialog', data () { return { /** ダイアログの表示状態 */ show: false, /** 入力したデータが有効かどうか */ valid: false, /** 日付選択メニューの表示状態 */ menu: false, /** ローディング状態 */ loading: false, /** 操作タイプ 'add' or 'edit' */ actionType: 'add', /** id */ id: '', /** 日付 */ date: '', /** タイトル */ title: '', /** 収支 'income' or 'outgo' */ inout: '', /** カテゴリ */ category: '', /** タグ */ tags: [], /** 金額 */ amount: 0, /** メモ */ memo: '', /** 収支カテゴリ一覧 */ incomeItems: ['カテ1', 'カテ2'], outgoItems: ['カテ3', 'カテ4'], /** 選択カテゴリ一覧 */ categoryItems: [], /** タグリスト */ tagItems: ['タグ1', 'タグ2'], /** 編集前の年月(編集時に使う) */ beforeYM: '', /** バリデーションルール */ titleRules: [ v => v.trim().length > 0 || 'タイトルは必須です', v => v.length <= 20 || '20文字以内で入力してください' ], tagRule: v => v.length <= 5 || 'タグは5種類以内で選択してください', amountRules: [ v => v >= 0 || '金額は0以上で入力してください', v => Number.isInteger(v) || '整数で入力してください' ], memoRule: v => v.length <= 50 || 'メモは50文字以内で入力してください' } }, computed: { /** ダイアログのタイトル */ titleText () { return this.actionType === 'add' ? 'データ追加' : 'データ編集' }, /** ダイアログのアクション */ actionText () { return this.actionType === 'add' ? '追加' : '更新' } }, methods: { /** * ダイアログを表示します。 * このメソッドは親から呼び出されます。 */ open (actionType, item) { this.show = true this.actionType = actionType this.resetForm(item) if (actionType === 'edit') { this.beforeYM = item.date.slice(0, 7) } }, /** キャンセルがクリックされたとき */ onClickClose () { this.show = false }, /** 追加/更新がクリックされたとき */ onClickAction () { // あとで実装 }, /** 収支が切り替わったとき */ onChangeInout () { if (this.inout === 'income') { this.categoryItems = this.incomeItems } else { this.categoryItems = this.outgoItems } this.category = this.categoryItems[0] }, /** フォームの内容を初期化します */ resetForm (item = {}) { const today = new Date() const year = today.getFullYear() const month = ('0' + (today.getMonth() + 1)).slice(-2) const date = ('0' + today.getDate()).slice(-2) this.id = item.id || '' this.date = item.date || `${year}-${month}-${date}` this.title = item.title || '' this.inout = item.income != null ? 'income' : 'outgo' if (this.inout === 'income') { this.categoryItems = this.incomeItems this.amount = item.income || 0 } else { this.categoryItems = this.outgoItems this.amount = item.outgo || 0 } this.category = item.category || this.categoryItems[0] this.tags = item.tags ? item.tags.split(',') : [] this.memo = item.memo || '' this.$refs.form.resetValidation() } } } </script>…重要だと思うところを説明します。
ホーム画面の検索フォームと同じように、
v-text-field
を使っています。
rules
を設定するだけで、いい感じにバリデーションしてくれます。ItemDialog.vue|48-54行目<!-- タイトル --> <v-text-field label="タイトル" v-model.trim="title" :counter="20" :rules="titleRules" />バリデーションルールの書き方// v には現在入力されているデータが入ってる v => /** OKにする条件 */ || /** NGのときに表示させる文字 */ルールはこのように複数設定できます。
ItemDialog.vue|168-171行目titleRules: [ v => v.trim().length > 0 || 'タイトルは必須です', v => v.length <= 20 || '20文字以内で入力してください' ],現状のままだとダイアログの動作確認できないので、
ホーム画面でダイアログを表示できるようにItemDialog.vue
をインポートします。Home.vue<template> <div> <v-card> <v-card-title> <!-- ~ 省略 ~ --> <!-- 追加ボタン --> <v-col class="text-right" cols="4"> <v-btn dark color="green" @click="onClickAdd"> <v-icon>mdi-plus</v-icon> </v-btn> </v-col> <!-- ~ 省略 ~ --> </v-card-title> <!-- テーブル --> <v-data-table> <!-- ~ 省略 ~ --> <!-- 操作列 --> <template v-slot:item.actions="{ item }"> <v-icon class="mr-2" @click="onClickEdit(item)">mdi-pencil</v-icon> <v-icon>mdi-delete</v-icon> </template> </v-data-table> </v-card> <!-- 追加/編集ダイアログ --> <ItemDialog ref="itemDialog"/> </div> </template> <script> import ItemDialog from '../components/ItemDialog.vue' export default { name: 'Home', components: { ItemDialog }, /** ~ 省略 ~ */ methods: { /** ~ 省略 ~ */ /** 追加ボタンがクリックされたとき */ onClickAdd () { this.$refs.itemDialog.open('add') }, /** 編集ボタンがクリックされたとき */ onClickEdit (item) { this.$refs.itemDialog.open('edit', item) } } } </script>テーブル右上に表示されている追加ボタン、
操作列の編集ボタンをクリックして、動作を確認してみます。追加ボタンをクリックしたときは何も入力されていないフォーム、
編集ボタンをクリックしたときは初期値が入力されているフォームが表示されればOKです。バリデーションも実行されるか確認してみます。
問題なく動いてそうです。コンポーネントの子要素には
ref
属性をつけるとthis.$refs.名前
でアクセスできます。<!-- 追加/編集ダイアログ --> <ItemDialog ref="itemDialog"/>今回はダイアログに
itemDialog
という名前をつけたので、this.$refs.itemDialog
ですね。追加ボタンをクリックしたとき、追加/編集ダイアログの
open
を実行することで
ダイアログの表示を行うようにしています。/** 追加ボタンがクリックされたとき */ onClickAdd () { this.$refs.itemDialog.open('add') },追加/編集ダイアログと同じように削除ダイアログも作成します。
新しくcomponents
ディレクトリの中にDeleteDialog.vue
を作成します。
コードは少なめですDeleteDialog.vue<template> <!-- 削除ダイアログ --> <v-dialog v-model="show" persistent max-width="290" > <v-card> <v-card-title/> <v-card-text class="black--text"> 「{{ item.title }}」を削除しますか? </v-card-text> <v-card-actions> <v-spacer/> <v-btn color="grey" text :disabled="loading" @click="onClickClose">キャンセル</v-btn> <v-btn color="red" text :loading="loading" @click="onClickDelete">削除</v-btn> </v-card-actions> </v-card> </v-dialog> </template> <script> export default { name: 'DeleteDialog', data () { return { /** ダイアログの表示状態 */ show: false, /** ローディング状態 */ loading: false, /** 受け取ったデータ */ item: {} } }, methods: { /** * ダイアログを表示します。 * このメソッドは親から呼び出されます。 */ open (item) { this.show = true this.item = item }, /** キャンセルがクリックされたとき */ onClickClose () { this.show = false }, /** 削除がクリックされたとき */ onClickDelete () { // あとで実装 } } } </script>追加/編集ダイアログと同じように、ホームで表示させます。
Home.vue<!-- ~ 省略 ~ --> </v-card> <!-- 追加/編集ダイアログ --> <ItemDialog ref="itemDialog"/> <!-- 削除ダイアログ --> <DeleteDialog ref="deleteDialog"/> </div> </template> <script> import ItemDialog from '../components/ItemDialog.vue' import DeleteDialog from '../components/DeleteDialog.vue' export default { name: 'Home', components: { ItemDialog, DeleteDialog }, /** ~ 省略 ~ */ methods: { /** ~ 省略 ~ */ /** 削除ボタンがクリックされたとき */ onClickDelete (item) { this.$refs.deleteDialog.open(item) } } } </script>削除ボタンをクリックして、ダイアログが表示されればOkです。
現時点のソースコード一覧はこちらから確認できます!
設定の画面だけ作る
次に、手をつけていなかった設定画面を作ります。
Settings.vue<template> <div class="form-wrapper"> <p>※設定はこのデバイスのみに保存されます。</p> <v-form v-model="valid"> <h3>アプリ設定</h3> <!-- アプリ名 --> <v-text-field label="アプリ名" v-model="settings.appName" :counter="30" :rules="[appNameRule]" /> <!-- API URL --> <v-text-field label="API URL" v-model="settings.apiUrl" :counter="150" :rules="[stringRule]" /> <!-- Auth Token --> <v-text-field label="Auth Token" v-model="settings.authToken" :counter="150" :rules="[stringRule]" /> <h3>カテゴリ/タグ設定</h3> <p>カンマ( , )区切りで入力してください。</p> <!-- 収入カテゴリ --> <v-text-field label="収入カテゴリ" v-model="settings.strIncomeItems" :counter="150" :rules="[stringRule, ...categoryRules]" /> <!-- 支出カテゴリ --> <v-text-field label="支出カテゴリ" v-model="settings.strOutgoItems" :counter="150" :rules="[stringRule, ...categoryRules]" /> <!-- タグ --> <v-text-field label="タグ" v-model="settings.strTagItems" :counter="150" :rules="[stringRule, tagRule]" /> <v-row class="mt-4"> <v-spacer/> <v-btn color="primary" :disabled="!valid" @click="onClickSave">保存</v-btn> </v-row> </v-form> </div> </template> <script> export default { name: 'Settings', data () { const createItems = v => v.split(',').map(v => v.trim()).filter(v => v.length !== 0) const itemMaxLength = v => createItems(v).reduce((a, c) => Math.max(a, c.length), 0) return { /** 入力したデータが有効かどうか */ valid: false, /** 設定 */ settings: { appName: 'GAS 家計簿', apiUrl: '', authToken: '', strIncomeItems: '給料, ボーナス, 繰越', strOutgoItems: '食費, 趣味, 交通費, 買い物, 交際費, 生活費, 住宅, 通信, 車, 税金', strTagItems: '固定費, カード' }, /** バリデーションルール */ appNameRule: v => v.length <= 30 || '30文字以内で入力してください', stringRule: v => v.length <= 150 || '150文字以内で入力してください', categoryRules: [ v => createItems(v).length !== 0 || 'カテゴリは1つ以上必要です', v => itemMaxLength(v) <= 4 || '各カテゴリは4文字以内で入力してください' ], tagRule: v => itemMaxLength(v) <= 4 || '各タグは4文字以内で入力してください' } }, methods: { onClickSave () { // あとで実装 } } } </script> <style> .form-wrapper { max-width: 500px; margin: auto; } </style>追加/編集ダイアログと同じようにフォームを表示させ、バリデーションさせています。
スプレッド構文を使うと、いい感じにバリデーションルールを使い回せます。
const rules = ['rule2', 'rule3'] console.log(['rule1', ...rules]) // -> ['rule1', 'rule2', 'rule3']Settings.vue|29-35行目<!-- 収入カテゴリ --> <v-text-field label="収入カテゴリ" v-model="settings.strIncomeItems" :counter="150" :rules="[stringRule, ...categoryRules]" />設定を保存/読み込みできるようにする
設定画面で保存ボタンを押しても入力したデータは保存されていません。
また、この状態だとホーム画面で設定を読み込むこともできません。ここで登場するのが
Vuex
です。状態(State
)を管理できます。
公式ドキュメントにある画像がわかりやすかったので引用します。とても大雑把に説明すると、
「画面からActions
を使って状態更新」→「State
から状態読み込み」という流れになります。今回は「設定」「家計簿データ」の状態管理に
Vuex
を使用します。
さっそく、設定を保存/読み込みできるようsrc/store/index.js
を書き換えます。
設定の内容は永続的に保存したいので、localStorage を利用します。store/index.jsimport Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) /** * State * Vuexの状態 */ const state = { /** 設定 */ settings: { appName: 'GAS 家計簿', apiUrl: '', authToken: '', strIncomeItems: '給料, ボーナス, 繰越', strOutgoItems: '食費, 趣味, 交通費, 買い物, 交際費, 生活費, 住宅, 通信, 車, 税金', strTagItems: '固定費, カード' } } /** * Mutations * ActionsからStateを更新するときに呼ばれます */ const mutations = { /** 設定を保存します */ saveSettings (state, { settings }) { state.settings = { ...settings } document.title = state.settings.appName localStorage.setItem('settings', JSON.stringify(settings)) }, /** 設定を読み込みます */ loadSettings (state) { const settings = JSON.parse(localStorage.getItem('settings')) if (settings) { state.settings = Object.assign(state.settings, settings) } document.title = state.settings.appName } } /** * Actions * 画面から呼ばれ、Mutationをコミットします */ const actions = { /** 設定を保存します */ saveSettings ({ commit }, { settings }) { commit('saveSettings', { settings }) }, /** 設定を読み込みます */ loadSettings ({ commit }) { commit('loadSettings') } } /** カンマ区切りの文字をトリミングして配列にします */ const createItems = v => v.split(',').map(v => v.trim()).filter(v => v.length !== 0) /** * Getters * 画面から取得され、Stateを加工して渡します */ const getters = { /** 収入カテゴリ(配列) */ incomeItems (state) { return createItems(state.settings.strIncomeItems) }, /** 支出カテゴリ(配列) */ outgoItems (state) { return createItems(state.settings.strOutgoItems) }, /** タグ(配列) */ tagItems (state) { return createItems(state.settings.strTagItems) } } const store = new Vuex.Store({ state, mutations, actions, getters }) export default store突然
Mutations
,Getters
が現れました。
こちらも公式ドキュメント画像の引用になりますが、
Vuex
では「Actions
」→「Mutations
」→「State
」という流れで状態を更新します。State は Mutations からしか変更しないようにします
Getters
はコメントにもありますが、State を加工して渡します。
Vuex 版computed
のようなものです。次に、設定画面で
Vuex
を使って設定保存できるようにします。Settings.vue<script> export default { name: 'Settings', data () { /** ~ 省略 ~ */ return { /** ~ 省略 ~ */ /** 設定 */ settings: { ...this.$store.state.settings }, /** ~ 省略 ~ */ } }, methods: { /** 保存ボタンがクリックされたとき */ onClickSave () { this.$store.dispatch('saveSettings', { settings: this.settings }) } } } </script>各コンポーネントでストアには
$store
でアクセスでき、
ストアからstate
やgetters
にアクセスできます。// Stateのsettingsにアクセス this.$store.state.settingsフォームの内容を書き換えるのと同時に State も書き換わるは困るので、
一度 settings の内容をコピーして使用するようにしています。/** 設定 */ settings: { ...this.$store.state.settings }
Actions
はdispatch
メソッドで実行できます。// dispatch('Action名', ペイロード) this.$store.dispatch('saveSettings', { settings: this.settings }) // 以下の形式でもOKです this.$store.dispatch( type: 'saveSettings', settings: this.settings )最後に、アプリ起動時に localStorage から読み込む処理を追加します。
ついでにアプリ名を反映させます。App.vue<template> <v-app> <!-- ツールバー --> <v-app-bar app color="green" dark> <!-- タイトル --> <v-toolbar-title>{{ appName }}</v-toolbar-title> <!-- ~ 省略 ~ --> </v-app-bar> <!-- ~ 省略 ~ --> </v-app> </template> <script> import { mapState } from 'vuex' export default { name: 'App', computed: mapState({ appName: state => state.settings.appName }), // Appインスタンス生成前に一度だけ実行されます beforeCreate () { this.$store.dispatch('loadSettings') } } </script>
beforeCreate
の中でloadSettings
を呼び出すようにしました。
mapState
を使うと、State のアクセスを簡潔にできます。
色々な書き方があるのでこちらも参考にしてみてください。// mapState を使わないと… this.$store.state.settings.appName // 長い // mapState を使うと… this.appName // 短い現時点のソースコード一覧はこちらから確認できます!
家計簿アプリの動作を実装してみる
それでは、フロント実装最後の仕上げに入っていきます!
家計簿データを追加/編集/削除できるようにします。Vuex ストア実装
家計簿のデータは State に保存します。
データは月ごとに管理したいので、以下のような構造で持つようにします。// 家計簿データ(abData)の構造 { '2020-06': [ { id: 'xxx', title: 'xxx', … }, { id: 'yyy', title: 'yyy', … }, ], '2020-07': [ { id: 'zzz', title: 'zzz', … } ], … }それでは、家計簿データの
Action
,Mutation
を実装します。store/index.js/** ~ 省略 ~ */ /** * State * Vuexの状態 */ const state = { /** 家計簿データ */ abData: {}, /** ~ 省略 ~ */ } /** * Mutations * ActionsからStateを更新するときに呼ばれます */ const mutations = { /** 指定年月の家計簿データをセットします */ setAbData (state, { yearMonth, list }) { state.abData[yearMonth] = list }, /** データを追加します */ addAbData (state, { item }) { const yearMonth = item.date.slice(0, 7) const list = state.abData[yearMonth] if (list) { list.push(item) } }, /** 指定年月のデータを更新します */ updateAbData (state, { yearMonth, item }) { const list = state.abData[yearMonth] if (list) { const index = list.findIndex(v => v.id === item.id) list.splice(index, 1, item) } }, /** 指定年月&IDのデータを削除します */ deleteAbData (state, { yearMonth, id }) { const list = state.abData[yearMonth] if (list) { const index = list.findIndex(v => v.id === id) list.splice(index, 1) } }, /** ~ 省略 ~ */ } /** * Actions * 画面から呼ばれ、Mutationをコミットします */ const actions = { /** 指定年月の家計簿データを取得します */ fetchAbData ({ commit }, { yearMonth }) { // サンプルデータを初期値として入れる const list = [ { id: 'a34109ed', date: `${yearMonth}-01`, title: '支出サンプル', category: '買い物', tags: 'タグ1', income: null, outgo: 2000, memo: 'メモ' }, { id: '7c8fa764', date: `${yearMonth}-02`, title: '収入サンプル', category: '給料', tags:'タグ1,タグ2', income: 2000, outgo: null, memo: 'メモ' } ] commit('setAbData', { yearMonth, list }) }, /** データを追加します */ addAbData ({ commit }, { item }) { commit('addAbData', { item }) }, /** データを更新します */ updateAbData ({ commit }, { beforeYM, item }) { const yearMonth = item.date.slice(0, 7) if (yearMonth === beforeYM) { commit('updateAbData', { yearMonth, item }) return } const id = item.id commit('deleteAbData', { yearMonth: beforeYM, id }) commit('addAbData', { item }) }, /** データを削除します */ deleteAbData ({ commit }, { item }) { const yearMonth = item.date.slice(0, 7) const id = item.id commit('deleteAbData', { yearMonth, id }) }, /** ~ 省略 ~ */ } /** ~ 省略 ~ */家計簿データを取得/追加/更新/削除する処理を追加しました。
どの処理も API 完成後に通信させます。今回の実装内容は家計簿データの操作なので、複雑な処理はありませんが、
更新だけ少し特殊なので補足します。// (Actions) /** データを更新します */ updateAbData ({ commit }, { beforeYM, item }) { const yearMonth = item.date.slice(0, 7) // 更新前後で年月の変更が無ければそのまま値を更新 if (yearMonth === beforeYM) { commit('updateAbData', { yearMonth, item }) return } // 更新があれば、更新前年月のデータから削除して、新しくデータ追加する const id = item.id commit('deleteAbData', { yearMonth: beforeYM, id }) commit('addAbData', { item }) },ホーム画面からストアを呼び出す
Home.vue<template> <div> <v-card> <v-card-title> <!-- 月選択 --> <v-col cols="8"> <v-menu ~ 省略 ~ > <!-- ~ 省略 ~ --> <v-date-picker ~ 省略 ~ > <v-spacer/> <v-btn text color="grey" @click="menu = false">キャンセル</v-btn> <v-btn text color="primary" @click="onSelectMonth">選択</v-btn> </v-date-picker> </v-menu> </v-col> <!-- ~ 省略 ~ --> </v-col> </v-card-title> <!-- ~ 省略 ~ --> </v-card> <!-- ~ 省略 ~ --> </div> </template> <script> import { mapState, mapActions } from 'vuex' /** ~ 省略 ~ */ export default { /** ~ 省略 ~ */ data () { /** ~ 省略 ~ */ return { /** ~ 省略 ~ */ /** テーブルに表示させるデータ */ tableData: [] } }, computed: { ...mapState({ /** 家計簿データ */ abData: state => state.abData }), /** ~ 省略 ~ */ }, methods: { ...mapActions([ /** 家計簿データを取得 */ 'fetchAbData' ]), /** 表示させるデータを更新します */ updateTable () { const yearMonth = this.yearMonth const list = this.abData[yearMonth] if (list) { this.tableData = list } else { this.fetchAbData({ yearMonth }) this.tableData = this.abData[yearMonth] } }, /** 月選択ボタンがクリックされたとき */ onSelectMonth () { this.$refs.menu.save(this.yearMonth) this.updateTable() }, /** ~ 省略 ~ */ }, created () { this.updateTable() } } </script>
mapState
は App.vue で利用しましたが、
それ以外にもmapActions
,mapGetters
などが用意されています。
スプレッド構文を使うといい感じに利用できます。methods: { ...mapActions([ /** 家計簿データを取得 */ /** * this.$store.dispatch('fetchAbData') を * this.fetchAbData として使えるようにする */ 'fetchAbData' ]), … }追加/編集ダイアログからストアを呼び出す
収支カテゴリ設定などを State から取得するのと、
フォームに入力されたデータで追加/更新できるようにします。ItemDialog.vue<script> import { mapActions, mapGetters } from 'vuex' export default { name: 'ItemDialog', data () { return { /** ~ 省略 ~ */ /** メモ */ memo: '', /** 選択可能カテゴリ一覧 */ categoryItems: [], /** 編集前の年月(編集時に使う) */ beforeYM: '', /** ~ 省略 ~ */ } }, computed: { ...mapGetters([ /** 収支カテゴリ */ 'incomeItems', 'outgoItems', /** タグ */ 'tagItems' ]), /** ~ 省略 ~ */ }, methods: { ...mapActions([ /** データ追加 */ 'addAbData', /** データ更新 */ 'updateAbData' ]), /** ~ 省略 ~ */ /** 追加/更新がクリックされたとき */ onClickAction () { const item = { date: this.date, title: this.title, category: this.category, tags: this.tags.join(','), memo: this.memo, income: null, outgo: null } item[this.inout] = this.amount || 0 if (this.actionType === 'add') { item.id = Math.random().toString(36).slice(-8) // ランダムな8文字のIDを生成 this.addAbData({ item }) } else { item.id = this.id this.updateAbData({ beforeYM: this.beforeYM, item }) } this.show = false }, /** ~ 省略 ~ */ } } </script>ダイアログからデータの追加/編集ができるか確認してみてください!
削除ダイアログからストアを呼び出す
DeleteDialog.vue<script> import { mapActions } from 'vuex' export default { name: 'DeleteDialog', /** ~ 省略 ~ */ methods: { ...mapActions([ /** データ削除 */ 'deleteAbData' ]), /** ~ 省略 ~ */ /** 削除がクリックされたとき */ onClickDelete () { this.deleteAbData({ item: this.item }) this.show = false } } } </script>ダイアログからデータの削除ができるか確認してみてください!
「Vue.js / Vue Router / Vuex でフロント実装してみる」は以上になります。
お疲れ様でした!現時点のソースコード一覧はこちらから確認できます!
Google Apps Script で REST API もどきを作ってみる
こちらから GAS で API の作成になります!!
「こだわりポイント」でも触れましたが、擬似的にメソッドを指定して
GET
で取得、POST
で追加、PUT
で更新、DELETE
で削除できる API を作成します。シート準備
まずはじめにシートの準備をします。
Google スプレッドシートで新しいシートを作成して、「ツール」タブ→「スクリプトエディター」をクリックします。
もし表示されていなかった場合は、「実行」タブから V8 ランタイムを有効にしてください。
プロジェクトの名前を「家計簿API」と保存して、
コード.gs
をapi.gs
にリネームします。
api.gs
の内容を書き換えます。api.gsconst ss = SpreadsheetApp.getActive() function test () { console.log(ss.getName()) }メニューで「test」が選択されていることを確認してから
ボタンをクリックします。
「Authorization Required」というダイアログが表示されるので、
「許可を確認」ボタンをクリックしたあと、スプレッドシートを作成したアカウントでログインして「許可」ボタンをクリックします。
Ctrl
+Enter
(mac はCommand
+Enter
) でログを確認できます。
作成したシートの名前が表示されればOKです。家計簿のテンプレートをつくる
まずはじめに、家計簿のテンプレートとなるシートを作成する関数
insertTemplate
を作ります。
シートのイメージを大雑把にまとめるとです。これをプログラムに落とし込みます。
api.gsconst ss = SpreadsheetApp.getActive() function test () { insertTemplate('2020-06') } /** * 指定年月のテンプレートシートを作成します * @param {String} yearMonth * @returns {Sheet} sheet */ function insertTemplate (yearMonth) { const { SOLID_MEDIUM, DOUBLE } = SpreadsheetApp.BorderStyle const sheet = ss.insertSheet(yearMonth, 0) const [year, month] = yearMonth.split('-') // 収支確認エリア sheet.getRange('A1:B1') .merge() .setValue(`${year}年 ${parseInt(month)}月`) .setFontWeight('bold') .setHorizontalAlignment('center') .setBorder(null, null, true, null, null, null, 'black', SOLID_MEDIUM) sheet.getRange('A2:A4') .setValues([['収入:'], ['支出:'], ['収支差:']]) .setFontWeight('bold') .setHorizontalAlignment('right') sheet.getRange('B2:B4') .setFormulas([['=SUM(F7:F)'], ['=SUM(G7:G)'], ['=B2-B3']]) .setNumberFormat('#,##0') sheet.getRange('A4:B4') .setBorder(true, null, null, null, null, null, 'black', DOUBLE) // テーブルヘッダー sheet.getRange('A6:H6') .setValues([['id', '日付', 'タイトル', 'カテゴリ', 'タグ', '収入', '支出', 'メモ']]) .setFontWeight('bold') .setBorder(null, null, true, null, null, null, 'black', SOLID_MEDIUM) sheet.getRange('F7:G') .setNumberFormat('#,##0') // カテゴリ別支出 sheet.getRange('J1') .setFormula('=QUERY(B7:H, "select D, sum(G), sum(G) / "&B3&" where G > 0 group by D order by sum(G) desc label D \'カテゴリ\', sum(G) \'支出\'")') sheet.getRange('J1:L1') .setFontWeight('bold') .setBorder(null, null, true, null, null, null, 'black', SOLID_MEDIUM) sheet.getRange('L1') .setFontColor('white') sheet.getRange('K2:K') .setNumberFormat('#,##0') sheet.getRange('L2:L') .setNumberFormat('0.0%') sheet.setColumnWidth(9, 21) return sheet }スプレッドシートは
SpreadsheetApp
を利用して取得します。
取得の方法は2つあります。
- スプレッドシートIDを指定する
openById(id)
- 紐付いているスプレッドシートを取得する
getActive()
今回はスプレッドシートと紐付いている GAS プロジェクトを作成したので、後者で取得します。
const ss = SpreadsheetApp.getActive()新規シートを作成するときには
insertSheet
メソッドを使います。
引数にシート名とインデックスを指定します。インデックスは0
で一番左に追加されます。
返り値は新規作成したシートです。const sheet = ss.insertSheet('シート名', インデックス)セル操作の流れは、範囲(
Range
)を取得してから各操作を実行します。
シートのgetRange
メソッドで範囲を取得できます。
A1 形式のほうが(個人的に)見やすいので、今回のプログラムではこちらに統一します。ex./** 単一のセルを取得する */ // getRange(行, 列) sheet.getRange(1, 2) // B1 // getRange(A1形式) sheet.getRange('B1') // B1 /** 複数のセルを取得する */ // getRange(開始行, 開始列, 何行分選択するか, 何列分選択するか) sheet.getRange(1, 2, 3, 4) // B1:E3 // getRange(A1形式) sheet.getRange('B1:E3') // B1:E3各セル操作は
Range
を返すので、メソッドチェーンを利用できます。
可能な操作はすべて公式リファレンスに記載されているので、こちらも確認してみてください。ex.メソッドチェーンsheet.getRange('A1') .func1() // どの操作も .func2() // A1に対して .func3() // 実行されるセル操作については重要な
setValue
,setValues
メソッドを説明します。
単一セルの値をセットするときはsetValue
、
複数セルの値をセットするときはsetValues
を使います。
setValues
では必ず2次元配列を渡します。改行してみると分かりやすいです。ex.// A1に"A1 value"をセット sheet.getRange('A1') .setValue('A1 value') // 複数セルの値をセットするときは // 2次元配列を渡します sheet.getRange('A1:B2') .setValues([ ['A1', 'B1'], ['A2', 'B2'] ]) // 1行(1列)だけでも2次元配列を渡します sheet.getRange('A6:H6') .setValues([ ['id', '日付', 'タイトル', 'カテゴリ', 'タグ', '収入', '支出', 'メモ'] ])また、
=
から始まる数式をセットしたい場合は、
setFormula
,setFormulas
メソッドを使います。ex.sheet.getRange('A1') .setFormula('=PI()') sheet.getRange('B2:B4') .setFormulas([ ['=SUM(F7:F)'], ['=SUM(G7:G)'], ['=B2-B3'] ])この状態で test を実行してみます。
2020-06
というシートが新しく作成され、テンプレートが書き込まれることを確認してください!データを追加する onPost をつくる
それでは API のプログラム作成に入ります!
API は成功時には何かしらの結果を返し、エラー時には{ error: 'メッセージ' }
を返す仕様にします。まずはデータの追加です。
onPost
と、
一応入力データのバリデーションを行うisValid
を作成します。api.gsconst ss = SpreadsheetApp.getActive() function test () { onPost({ item: { date: '2020-07-01', title: '支出サンプル', category: '食費', tags: 'タグ1,タグ2', income: null, outgo: 3000, memo: 'メモメモ' } }) } /** --- API --- */ /** * データを追加します * @param {Object} params * @param {Object} params.item 家計簿データ * @returns {Object} 追加した家計簿データ */ function onPost ({ item }) { if (!isValid(item)) { return { error: '正しい形式で入力してください' } } const { date, title, category, tags, income, outgo, memo } = item const yearMonth = date.slice(0, 7) const sheet = ss.getSheetByName(yearMonth) || insertTemplate(yearMonth) const id = Utilities.getUuid().slice(0, 8) const row = ["'" + id, "'" + date, "'" + title, "'" + category, "'" + tags, income, outgo, "'" + memo] sheet.appendRow(row) return { id, date, title, category, tags, income, outgo, memo } } /** --- common --- */ /** * 指定年月のテンプレートシートを作成します * @param {String} yearMonth * @returns {Sheet} sheet */ function insertTemplate (yearMonth) { /** ~ 省略 ~ */ } /** * データが正しい形式か検証します * @param {Object} item * @returns {Boolean} isValid */ function isValid (item = {}) { const strKeys = ['date', 'title', 'category', 'tags', 'memo'] const keys = [...strKeys, 'income', 'outgo'] // すべてのキーが存在するか for (const key of keys) { if (item[key] === undefined) return false } // 収支以外が文字列であるか for (const key of strKeys) { if (typeof item[key] !== 'string') return false } // 日付が正しい形式であるか const dateReg = /^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/ if (!dateReg.test(item.date)) return false // 収支のどちらかが入力されているか const { income: i, outgo: o } = item if ((i === null && o === null) || (i !== null && o !== null)) return false // 入力された収支が数字であるか if (i !== null && typeof i !== 'number') return false if (o !== null && typeof o !== 'number') return false return true }シートの取得は
getSheetByName
でシート名を指定して取得します。
シートがなかった場合はnull
が返ってくるので、insertTemplate
が実行されます。// 指定年月シートを取得する、なかったらテンプレートシートを作成する const sheet = ss.getSheetByName(yearMonth) || insertTemplate(yearMonth)また、シートには
appendRow
というシンプルで便利なメソッドが用意されているので、
引数に配列を渡すだけで簡単にデータの追加をできます。収支以外は文字列として扱ってほしいので、値の前にシングルクォートを付与してからシートに追加します。
値をセットするとき、文字列を渡しても数字や日付などは自動で変換されるので注意が必要です。ex.const a1 = sheet.getRange('A1').setValue("100").getValue() const b1 = sheet.getRange('B1').setValue("'100").getValue() console.log(typeof a1) // -> "number" console.log(typeof b1) // -> "string"ID は
Utilities
のgetUuid
を利用して UUID の先頭8文字だけ切り取るという謎のプログラムで生成しています。
公式リファレンスで使える便利メソッドが記載されているので、ぜひ確認してみてください。const id = Utilities.getUuid().slice(0, 8)この状態で test を実行してみます。
シートが新しく作成され、データの追加を確認してください!データ取得する onGet をつくる
追加ができたら、次は取得してみたいですね。
onGet
を作ります。api.gsconst ss = SpreadsheetApp.getActive() function test () { const result = onGet({ yearMonth: '2020-07' }) console.log(result) } /** --- API --- */ /** * 指定年月のデータ一覧を取得します * @param {Object} params * @param {String} params.yearMonth 年月 * @returns {Object[]} 家計簿データ */ function onGet ({ yearMonth }) { const ymReg = /^[0-9]{4}-(0[1-9]|1[0-2])$/ if (!ymReg.test(yearMonth)) { return { error: '正しい形式で入力してください' } } const sheet = ss.getSheetByName(yearMonth) const lastRow = sheet ? sheet.getLastRow() : 0 if (lastRow < 7) { return [] } const list = sheet.getRange('A7:H' + lastRow).getValues().map(row => { const [id, date, title, category, tags, income, outgo, memo] = row return { id, date, title, category, tags, income: (income === '') ? null : income, outgo: (outgo === '') ? null : outgo, memo } }) return list } /** ~ 省略 ~ */テーブルのヘッダーが
A6:H6
にあるので、A7:H{最終行}
のデータを取得します。
シートの最終行は
getLastRow
で取得できます。
指定年月のシートが存在しない場合も考慮して、最終行が7未満の場合は空の配列を返します。const sheet = ss.getSheetByName(yearMonth) const lastRow = sheet ? sheet.getLastRow() : 0 if (lastRow < 7) { return [] }データを返すときはオブジェクトにして返したいので、
getValues
で受け取った2次元配列をmap
でオブジェクトに加工します。空白セルは空文字(
''
)として取得されるので、収支だけ注意が必要です。ex.const values = [ ['xxx', '2020-07-01', 'sample1'], ['yyy', '2020-07-02', 'sample2'] ] const list = values.map(row => { return { id: row[0], date: row[1], title: row[2] } }) console.log(list) // -> [ // { id: "xxx", date: "2020-07-01", title: "sample1" }, // { id: "yyy", date: "2020-07-02", title: "sample2" } // ]この状態で test を実行してみます。
追加したデータがオブジェクトの配列で返ってくることを確認してください!データ削除する onDelete をつくる
あと機能はあと2つです!
onDelete
を作ります。api.gsconst ss = SpreadsheetApp.getActive() function test () { const result = onDelete({ yearMonth: '2020-07', id: 'xxxxxxxx' }) console.log(result) } /** --- API --- */ function onGet ({ yearMonth }) { /** ~ 省略 ~ */ } function onPost ({ item }) { /** ~ 省略 ~ */ } /** * 指定年月&idのデータを削除します * @param {Object} params * @param {String} params.yearMonth 年月 * @param {String} params.id id * @returns {Object} メッセージ */ function onDelete ({ yearMonth, id }) { const ymReg = /^[0-9]{4}-(0[1-9]|1[0-2])$/ const sheet = ss.getSheetByName(yearMonth) if (!ymReg.test(yearMonth) || sheet === null) { return { error: '指定のシートは存在しません' } } const lastRow = sheet.getLastRow() const index = sheet.getRange('A7:A' + lastRow).getValues().flat().findIndex(v => v === id) if (index === -1) { return { error: '指定のデータは存在しません' } } sheet.deleteRow(index + 7) return { message: '削除完了しました' } } /** ~ 省略 ~ */内容はシンプルです。指定年月&id のデータが存在したら
deleteRow
で行を削除するだけです。
A7:A{最終行}
で範囲の値を取得すると、2次元配列になっているのでフラットにしてから id を探します。ex.const values = [['xxx'], ['yyy'], ['zzz']] const flatted = values.flat() console.log(flatted) // -> ['xxx', 'yyy', 'zzz'] console.log(flatted.findIndex(v => v === 'yyy')) // -> 1インデックスが見つかれば、インデックスに7行分足した行を削除するだけです。
sheet.deleteRow(index + 7)この状態で test の指定年月&id を書き換えて実行してみます。
指定のデータが削除され、「削除完了しました」というメッセージをログで確認してください!データ更新する onPut をつくる
最後の機能です!
onPut
を作ります。api.gsconst ss = SpreadsheetApp.getActive() function test () { onPut({ beforeYM: '2020-07', item: { id: 'xxxxxxxx', date: '2020-07-31', title: '更新サンプル', category: '食費', tags: 'タグ1,タグ2', income: null, outgo: 5000, memo: '更新したよ' } }) } /** --- API --- */ function onGet ({ yearMonth }) { /** ~ 省略 ~ */ } function onPost ({ item }) { /** ~ 省略 ~ */ } function onDelete ({ yearMonth, id }) { /** ~ 省略 ~ */ } /** * 指定データを更新します * @param {Object} params * @param {String} params.beforeYM 更新前の年月 * @param {Object} params.item 家計簿データ * @returns {Object} 更新後の家計簿データ */ function onPut ({ beforeYM, item }) { const ymReg = /^[0-9]{4}-(0[1-9]|1[0-2])$/ if (!ymReg.test(beforeYM) || !isValid(item)) { return { error: '正しい形式で入力してください' } } // 更新前と後で年月が違う場合、データ削除と追加を実行 const yearMonth = item.date.slice(0, 7) if (beforeYM !== yearMonth) { onDelete({ yearMonth: beforeYM, id: item.id }) return onPost({ item }) } const sheet = ss.getSheetByName(yearMonth) if (sheet === null) { return { error: '指定のシートは存在しません' } } const id = item.id const lastRow = sheet.getLastRow() const index = sheet.getRange('A7:A' + lastRow).getValues().flat().findIndex(v => v === id) if (index === -1) { return { error: '指定のデータは存在しません' } } const row = index + 7 const { date, title, category, tags, income, outgo, memo } = item const values = [["'" + date, "'" + title, "'" + category, "'" + tags, income, outgo, "'" + memo]] sheet.getRange(`B${row}:H${row}`).setValues(values) return { id, date, title, category, tags, income, outgo, memo } } /** ~ 省略 ~ */編集だけ「更新前と後で年月が違う場合」を考慮しないといけません。
削除と追加の処理はonDelete
とonPost
に任せます。// 更新前と後で年月が違う場合、データ削除と追加を実行 const yearMonth = item.date.slice(0, 7) if (beforeYM !== yearMonth) { onDelete({ yearMonth: beforeYM, id: item.id }) return onPost({ item }) }同じシートで完結できる場合は id 列以外の
B?:H?
をsetValues
で更新します。
編集する行はデータ削除の時と同じように探します。const values = [["'" + date, "'" + title, "'" + category, "'" + tags, income, outgo, "'" + memo]] sheet.getRange(`B${row}:H${row}`).setValues(values)この状態で test の編集前年月と item の id を書き換えて実行してみます。
id 列以外のデータが更新されることを確認してください!リクエストを受け取れるようにする
機能がすべて揃ったので、GAS 側でリクエストを受け取れるようにします。
GAS ではdoGet
,doPost
という関数を作ると、GET
,POST
を受け取ることができます。この画像3回目の登場になりますが、
doPost
で受け取り、
onGet
,onPost
,onPut
,onDelete
に振り分ける処理を追加します。const ss = SpreadsheetApp.getActive() const authToken = PropertiesService.getScriptProperties().getProperty('authToken') || '' /** * レスポンスを作成して返します * @param {*} content * @returns {TextOutput} */ function response (content) { const res = ContentService.createTextOutput() res.setMimeType(ContentService.MimeType.JSON) res.setContent(JSON.stringify(content)) return res } /** * アプリにPOSTリクエストが送信されたとき実行されます * @param {Event} e * @returns {TextOutput} */ function doPost (e) { let contents try { contents = JSON.parse(e.postData.contents) } catch (e) { return response({ error: 'JSONの形式が正しくありません' }) } if (contents.authToken !== authToken) { return response({ error: '認証に失敗しました' }) } const { method = '', params = {} } = contents let result try { switch (method) { case 'POST': result = onPost(params) break case 'GET': result = onGet(params) break case 'PUT': result = onPut(params) break case 'DELETE': result = onDelete(params) break default: result = { error: 'methodを指定してください' } } } catch (e) { result = { error: e } } return response(result) } /** --- API --- */ /** ~ 省略 ~ */GAS でレスポンスを返すときは
ContentService
を利用します。
作成した API では JSON しか返さないので mime type にはMimeType.JSON
を指定します。function response (content) { const res = ContentService.createTextOutput() // レスポンスの Content-Type ヘッダーに "application/json" を設定する res.setMimeType(ContentService.MimeType.JSON) // オブジェクトを文字列にしてからレスポンスに詰め込む res.setContent(JSON.stringify(content)) return res }次に
doPost
の中をみていきます。
送られたリクエストはe.postData.contents
で取得できます。
文字列なので JSON にパースします。一応 try catch で囲んでおきます。let contents try { contents = JSON.parse(e.postData.contents) } catch (e) { return response({ error: 'JSONの形式が正しくありません' }) }受け取るリクエストの内容はこのような形式としてます。
リクエストの構造{ method: 'GET or POST or PUT or DELETE', authToken: '認証情報', params: { // 任意の処理の引数となるデータ } }誰でもアクセス可能な URL を発行するので、認証情報
authToken
を持っている人しかアクセスできないようにします。
認証情報はソースコードに書きたくないので、PropertiesService
を利用してスクリプトのプロパティから取得します。「ファイル」タブ→「プロジェクトのプロパティ」→「スクリプトのプロパティ」から設定できます。
const authToken = PropertiesService.getScriptProperties().getProperty('authToken') || ''処理はシンプルに case 文で分けます。
実行中にエラー起きても大丈夫なように、一応 try catch で囲んでおきます。let result try { switch (method) { case 'POST': result = onPost(params) break case 'GET': result = onGet(params) break case 'PUT': result = onPut(params) break case 'DELETE': result = onDelete(params) break default: result = { error: 'methodを指定してください' } } } catch (e) { result = { error: e } }最後に実行結果をレスポンスとして返します。
return response(result)ついに API 完成です!!
API を叩いてみる
API URL を発行します。
「公開」タブ→「ウェブ アプリケーションとして導入」をクリックします。
「Project version」は「New」、
「Execute the app as」は「Me (自分のメールアドレス)」、
「Who has access to the app」は「Anyone, even anonymous」
で「Deploy」ボタンをクリックします。
「Deploy as web app」というダイアログが表示されれば、準備完了です。
「Current web app URL」の内容をコピーしておきます。
※実際の URL はもっと長いです。
curl
などを使ってこの API を叩いてみます。
authToken や yearMonth の値は置き換えてください。> curl -L -d "{\"method\":\"GET\",\"authToken\":\"\",\"params\":{\"yearMonth\":\"2020-07\"}}" https://script.google.com/macros/s/xxxxx/exec [{"id":"5e30de41","date":"2020-07-31","title":"サンプル","category":"食費","tags":"タグ1,タグ2","income":null,"outgo":5000,"memo":"メモメモ"}]データが正常に返ってくればOKです!
「Google Apps Script で REST API もどきを作ってみる」は以上になります。
お疲れ様でした!現時点のソースコード一覧はこちらから確認できます!
作った API と axios で実際に通信してみる
それではフロントと API を連携させて、家計簿を完成させていきます!
まずは、
axios
というライブラリをプロジェクトに追加します。
API にアクセスする際よく利用されます。> yarn add axios
Vuex
の中でaxios
を使って API にアクセスします。
この図のActions <---> Backend API
の部分を実装します。API クライアントをつくる
src
の中に新しくapi
ディレクトリを作成し、
その中にgasApi.js
を作成します。このリクエストを送れるようにします。
リクエストの構造{ method: 'GET or POST or PUT or DELETE', authToken: '認証情報', params: { // 任意の処理の引数となるデータ } }gasApi.jsimport axios from 'axios' // 共通のヘッダーを設定したaxiosのインスタンス作成 const gasApi = axios.create({ headers: { 'content-type': 'application/x-www-form-urlencoded' } }) // response共通処理 // errorが含まれていたらrejectする gasApi.interceptors.response.use(res => { if (res.data.error) { return Promise.reject(res.data.error) } return Promise.resolve(res) }, err => { return Promise.reject(err) }) /** * APIのURLを設定します * @param {String} url */ const setUrl = url => { gasApi.defaults.baseURL = url } /** * authTokenを設定します * @param {String} token */ let authToken = '' const setAuthToken = token => { authToken = token } /** * 指定年月のデータを取得します * @param {String} yearMonth * @returns {Promise} */ const fetch = yearMonth => { return gasApi.post('', { method: 'GET', authToken, params: { yearMonth } }) } /** * データを追加します * @param {Object} item * @returns {Promise} */ const add = item => { return gasApi.post('', { method: 'POST', authToken, params: { item } }) } /** * 指定年月&idのデータを削除します * @param {String} yearMonth * @param {String} id * @returns {Promise} */ const $delete = (yearMonth, id) => { return gasApi.post('', { method: 'DELETE', authToken, params: { yearMonth, id } }) } /** * データを更新します * @param {String} beforeYM * @param {Object} item * @returns {Promise} */ const update = (beforeYM, item) => { return gasApi.post('', { method: 'PUT', authToken, params: { beforeYM, item } }) } export default { setUrl, setAuthToken, fetch, add, delete: $delete, update }最初に共通の設定をしたインスタンスを作成します。あとからデフォルト設定を上書きもできます。
// 共通のヘッダーを設定したaxiosのインスタンス作成 const gasApi = axios.create({ headers: { 'content-type': 'application/x-www-form-urlencoded' } }) // リクエスト先のURLを変更する gasApi.defaults.baseURL = 'https://xxxxx.com'インスタンスを作成すると
get
,post
,put
,delete
などのメソッドが使えます。
このメソッドで各リクエストを送信できます。今回は API の仕様上すべてpost
を使います。gasApi.post(url, data)また、
interceptors
を利用するとリクエスト時、レスポンス時の共通処理を設定できます。
今回はレスポンスの内容にerror
が含まれていた場合、reject してエラーにします。// response共通処理 // errorが含まれていたらrejectする gasApi.interceptors.response.use(res => { if (res.data.error) { return Promise.reject(res.data.error) } return Promise.resolve(res) }, err => { return Promise.reject(err) })API からデータを取得する
それでは、作成した API クライアントを使用して実際に通信してみます。
store/index.jsimport Vue from 'vue' import Vuex from 'vuex' import gasApi from '../api/gasApi' Vue.use(Vuex) /** * State * Vuexの状態 */ const state = { /** 家計簿データ */ abData: {}, /** ローディング状態 */ loading: { fetch: false, add: false, update: false, delete: false }, /** エラーメッセージ */ errorMessage: '', /** 設定 */ settings: { /** ~ 省略 ~ */ } } /** * Mutations * ActionsからStateを更新するときに呼ばれます */ const mutations = { /** ~ 省略 ~ */ /** ローディング状態をセットします */ setLoading (state, { type, v }) { state.loading[type] = v }, /** エラーメッセージをセットします */ setErrorMessage (state, { message }) { state.errorMessage = message }, /** 設定を保存します */ saveSettings (state, { settings }) { state.settings = { ...settings } const { appName, apiUrl, authToken } = state.settings document.title = appName gasApi.setUrl(apiUrl) gasApi.setAuthToken(authToken) // 家計簿データを初期化 state.abData = {} localStorage.setItem('settings', JSON.stringify(settings)) }, /** 設定を読み込みます */ loadSettings (state) { const settings = JSON.parse(localStorage.getItem('settings')) if (settings) { state.settings = Object.assign(state.settings, settings) } const { appName, apiUrl, authToken } = state.settings document.title = appName gasApi.setUrl(apiUrl) gasApi.setAuthToken(authToken) } } /** * Actions * 画面から呼ばれ、Mutationをコミットします */ const actions = { /** 指定年月の家計簿データを取得します */ async fetchAbData ({ commit }, { yearMonth }) { const type = 'fetch' commit('setLoading', { type, v: true }) try { const res = await gasApi.fetch(yearMonth) commit('setAbData', { yearMonth, list: res.data }) } catch (e) { commit('setErrorMessage', { message: e }) commit('setAbData', { yearMonth, list: [] }) } finally { commit('setLoading', { type, v: false }) } }, /** ~ 省略 ~ */ } /** ~ 省略 ~ */
import
で作成したクライアントを使えるようにして、
state にローディング状態とエラーメッセージを追加します。import gasApi from '../api/gasApi'/** ローディング状態 */ loading: { fetch: false, add: false, update: false, delete: false }, /** エラーメッセージ */ errorMessage: '',
saveSettings
,loadSettings
内でアプリ設定の apiUrl, authToken をgasApi
に反映させます。const { appName, apiUrl, authToken } = state.settings document.title = appName gasApi.setUrl(apiUrl) gasApi.setAuthToken(authToken)
Actions
の中でクライアントを使ってリクエストを送信します。/** 指定年月の家計簿データを取得します */ async fetchAbData ({ commit }, { yearMonth }) { const type = 'fetch' // 取得の前にローディングをtrueにする commit('setLoading', { type, v: true }) try { // APIにリクエスト送信 const res = await gasApi.fetch(yearMonth) // 取得できたらabDataにセットする commit('setAbData', { yearMonth, list: res.data }) } catch (e) { // エラーが起きたらメッセージをセット commit('setErrorMessage', { message: e }) // 空の配列をabDataにセット commit('setAbData', { yearMonth, list: [] }) } finally { // 最後に成功/失敗関係なくローディングをfalseにする commit('setLoading', { type, v: false }) } }ホーム画面で
fetchAdData
を呼んでいた箇所も変更が必要なので、対応させます。Home.vueexport default { name: 'Home', /** ~ 省略 ~ */ data () { const today = new Date() const year = today.getFullYear() const month = ('0' + (today.getMonth() + 1)).slice(-2) return { /** 月選択メニューの状態 */ menu: false, /** 検索文字 */ search: '', /** 選択年月 */ yearMonth: `${year}-${month}`, /** テーブルに表示させるデータ */ tableData: [] } }, computed: { ...mapState({ /** 家計簿データ */ abData: state => state.abData, /** ローディング状態 */ loading: state => state.loading.fetch, }), /** ~ 省略 ~ */ }, methods: { /** ~ 省略 ~ */ /** 表示させるデータを更新します */ async updateTable () { const yearMonth = this.yearMonth const list = this.abData[yearMonth] if (list) { this.tableData = list } else { await this.fetchAbData({ yearMonth }) this.tableData = this.abData[yearMonth] } }, /** ~ 省略 ~ */ } }data の中で持っていた
loading
は消して、State のloading
を使うようにします。computed: { ...mapState({ /** 家計簿データ */ abData: state => state.abData, /** ローディング状態 */ loading: state => state.loading.fetch, }), /** ~ 省略 ~ */ },
fetchAbData
はPromise
を返すようにしたのでasync/await
に直します。async updateTable () { /** ~ 省略 ~ */ await this.fetchAbData({ yearMonth }) /** ~ 省略 ~ */ },このままだと通信でエラーが起きたときにメッセージが表示されないので、
App.vue
にエラーメッセージを表示させるようにします。App.vue<template> <v-app> <!-- ~ 省略 ~ --> <v-main> <!-- ~ 省略 ~ --> </v-main> <!-- スナックバー --> <v-snackbar v-model="snackbar" color="error">{{ errorMessage }}</v-snackbar> </v-app> </template> <script> import { mapState } from 'vuex' export default { name: 'App', data () { return { snackbar: false } }, computed: mapState({ appName: state => state.settings.appName, errorMessage: state => state.errorMessage }), watch: { errorMessage () { this.snackbar = true } }, /** ~ 省略 ~ */ } </script>
watch
でerrorMessage
を監視して、変更のあったタイミングでスナックバーを表示させます。
スナックバーは一定時間経過すると自動で消えます。watch: { // errorMessageに変更があったら errorMessage () { // スナックバーを表示 this.snackbar = true } },API との疎通確認をしてみます!
家計簿アプリの設定を開き、「API URL」と「Auth Token」を入力して、「保存」ボタンをクリック。
※authToken
を設定してない方は空のままでOKです。
ホーム画面に戻ってスプレッドシートのデータが表示されるか確認してみてください!
API で追加/更新できるようにする
次に、
ItemDialog
から API を使って追加/更新できるようにします。
さきほどと同じようにActions
との内容を書き換えます。store/index.js/** ~ 省略 ~ */ const actions = { /** 指定年月の家計簿データを取得します */ async fetchAbData ({ commit }, { yearMonth }) { /** ~ 省略 ~ */ }, /** データを追加します */ async addAbData ({ commit }, { item }) { const type = 'add' commit('setLoading', { type, v: true }) try { const res = await gasApi.add(item) commit('addAbData', { item: res.data }) } catch (e) { commit('setErrorMessage', { message: e }) } finally { commit('setLoading', { type, v: false }) } }, /** データを更新します */ async updateAbData ({ commit }, { beforeYM, item }) { const type = 'update' const yearMonth = item.date.slice(0, 7) commit('setLoading', { type, v: true }) try { const res = await gasApi.update(beforeYM, item) if (yearMonth === beforeYM) { commit('updateAbData', { yearMonth, item }) return } const id = item.id commit('deleteAbData', { yearMonth: beforeYM, id }) commit('addAbData', { item: res.data }) } catch (e) { commit('setErrorMessage', { message: e }) } finally { commit('setLoading', { type, v: false }) } }, /** ~ 省略 ~ */ } /** ~ 省略 ~ */
ItemDialog
もasync/await
に対応させます。ItemDialog.vue/** ~ 省略 ~ */ import { mapActions, mapGetters, mapState } from 'vuex' export default { name: 'ItemDialog', data () { return { /** ダイアログの表示状態 */ show: false, /** 入力したデータが有効かどうか */ valid: false, /** 日付選択メニューの表示状態 */ menu: false, /** 操作タイプ 'add' or 'edit' */ actionType: 'add', /** ~ 省略 ~ */ } }, computed: { /** ~ 省略 ~ */ ...mapState({ /** ローディング状態 */ loading: state => state.loading.add || state.loading.update }), /** ~ 省略 ~ */ }, methods: { /** ~ 省略 ~ */ /** 追加/更新がクリックされたとき */ async onClickAction () { const item = { date: this.date, title: this.title, category: this.category, tags: this.tags.join(','), memo: this.memo, income: null, outgo: null } item[this.inout] = this.amount || 0 if (this.actionType === 'add') { await this.addAbData({ item }) } else { item.id = this.id await this.updateAbData({ beforeYM: this.beforeYM, item }) } this.show = false }, /** ~ 省略 ~ */ } }追加も編集も同じコンポーネントで行っているので、
どちらかが実行中であれば loading が true となるようにします。...mapState({ /** ローディング状態 */ loading: state => state.loading.add || state.loading.update }),追加/編集がダイアログから実行できるか確認してみます!
どちらも実行できればOKです!スプレッドシートも確認してみてください。API で削除できるようにする
最後に、
DeleteDialog
から API を使って削除できるようにします。store/index.js/** ~ 省略 ~ */ const actions = { /** 指定年月の家計簿データを取得します */ async fetchAbData ({ commit }, { yearMonth }) { /** ~ 省略 ~ */ }, /** データを追加します */ async addAbData ({ commit }, { item }) { /** ~ 省略 ~ */ }, /** データを更新します */ async updateAbData ({ commit }, { beforeYM, item }) { /** ~ 省略 ~ */ }, /** データを削除します */ async deleteAbData ({ commit }, { item }) { const type = 'delete' const yearMonth = item.date.slice(0, 7) const id = item.id try { await gasApi.delete(yearMonth, id) commit('deleteAbData', { yearMonth, id }) } catch (e) { commit('setErrorMessage', { message: e }) } finally { commit('setLoading', { type, v: false }) } }, /** ~ 省略 ~ */ } /** ~ 省略 ~ */DeleteDialog.vue/** ~ 省略 ~ */ import { mapActions, mapState } from 'vuex' export default { name: 'DeleteDialog', data () { return { /** ダイアログの表示状態 */ show: false, /** 受け取ったデータ */ item: {} } }, computed: mapState({ /** ローディング状態 */ loading: state => state.loading.delete }), methods: { /** ~ 省略 ~ */ /** 削除がクリックされたとき */ async onClickDelete () { await this.deleteAbData({ item: this.item }) this.show = false } } }削除がダイアログから実行できるか確認してみます!
実行できればOKです!スプレッドシートも確認してみてください。ハンズオンは以上になります。お疲れ様でした!
ホーム画面で収支の総計を確認できるようにしたり、毎月1日に先月の収入を自動で繰り越す GAS プログラムを追加したり…。
フロントに限らず、GAS 側も自分好みにしてみてください!
ハンズオン完成時点のソースコード一覧はこちらから確認できます!
さいごに
Vue.js の勉強用に作成したものなので、
改善できるところなどありましたらコメントで教えていただけると嬉しいです!ハンズオンを最後まで進めていただいた方、上から飛んできた方も
最後まで閲覧いただきありがとうございました!
- 投稿日:2020-07-05T17:42:46+09:00
【初学者向け】Vue CLIを使ってTODOアプリを作る
はじめに
作成するアプリについて
本記事ではVue CLIを使って、簡単なTODOアプリを作成していきます。
機能としては以下の通りです。
- TODO一覧の表示
- TODO追加
- TODO削除
対象読者
- progateのhtml, javascriptコースを完了した方
- Vue.jsを勉強し始めた方参考文献
Vue.jsでTodoアプリを作ってみよう
kenpapa (著)前提条件
以下がインストール済みであることを前提とします。
- Visual Studio Code
- node.js
- npm
目次
- Vue CLIのインストール
- プロジェクト作成
- サンプルアプリの起動
TODO追加機能実装
- 入力フォームの作成
- TODOを格納する配列を定義
- クリックイベントの実装
- 追加処理の実装
- TODOを一覧表示
- チェック処理の実装
TODO削除機能実装
- クリックイベントの実装
- 削除処理の実装
Vue CLIのインストール
Vue CLIとは、Vue.jsアプリケーションの雛形を簡単に作成できるツールです。
コマンドベースでサンプルアプリを作成できます。では、Vue CLIをインストールしていきましょう。
Macであればターミナル、Windowsであればコマンドプロンプトを起動し、以下のコマンドを実行してください。npm install -g @vue/cliプロジェクトの作成
Vue CLIのインストールが完了したら、プロジェクトを作成するディレクトリに移動してください。
今回はデスクトップ直下に"todo-app"というプロジェクト名で作成します。
ターミナルで以下コマンドを実行してください。Desktop $ vue create todo-app
少し待つとプリセットの選択を求められます。
今回は追加で機能を設定できるManually select featuresを選択します。Desktop $ vue create todo-app Vue CLI v4.4.4 ? Please pick a preset: default (babel, eslint) ❯ Manually select featuresデフォルトでBabelとLinter / Formatterにチェックが付いているかと思いますが、追加でRouterを選択します。
選択するにはカーソルを合わせてspaceキーを押下してください(Macの場合)
チェックがついたらEnterを押下してください。? Please pick a preset: Manually select features ? Check the features needed for your project: ◉ Babel ◯ TypeScript ◯ Progressive Web App (PWA) Support ❯◉ Router ◯ Vuex ◯ CSS Pre-processors ◉ Linter / Formatter ◯ Unit Testing ◯ E2E Testing以降もいくつか選択を求められますが、全てデフォルトで大丈夫です。
全て選択するとプロジェクトの作成が始まります。サンプルアプリの起動
それでは動作確認のためアプリを起動してみましょう。
cdコマンドでプロジェクトルートディレクトリに移動します。Desktop $ cd todo-app todo-app $起動コマンドを叩きます。
todo-app $ npm run serve
では、実際にアクセスしてみましょう。
任意のブラウザでhttp://localhost:8080/にアクセスしてください。
以下の画面が表示されるかと思います。
これで動作確認は完了です。
アプリの起動を停止しましょう。
ctrl + cで停止させてください。
TODOアプリの確認
では、TODOアプリを作成していきましょう。
作成したプロジェクトをVisual Studio Codeで開いてください。
App.vueを以下のように修正して、保存してください。
App.vue<template> <div> <h3>My TODO</h3> <input v-model="newTodo" placeholder="Input here..."> <button v-on:click="addTodo()">ADD</button> <h5>ToDo List</h5> <ul> <li v-for="(todo, i) in todos" v-bind:key="i"> {{ todo }} <button v-on:click="deleteTodo(i)">DEL</button> </li> </ul> </div> </template> <script> export default { data() { return { todos: [], newTodo: "" } }, methods: { addTodo() { if (this.newTodo === "") return; this.todos.push(this.newTodo); this.newTodo = ""; }, deleteTodo(i) { this.todos.splice(i, 1); } } } </script>再度アプリを起動してみましょう。
また、Visual Studio Code内でターミナルを起動することも可能です。
上部メニューのターミナル>新しいターミナルから起動してください。起動後、再度"http://localhost:8080/"にアクセスしてください。
以下のような画面になっているかと思います。
入力フォームに適当な値を入力して、ADDボタンを押してみてください。
こちらが今回作成するアプリになります。
ここではアプリのイメージを掴むため、コピペしていただきましたが、以降では順を追って実装内容を説明していきます。
初学者の方は、App.vueの記載を全て削除して、一から自身でコーディングしてみることをおすすめします。TODO追加機能の実装
では、実装内容を見ていきましょう。
一からコーディングされる方向けに説明していきます。まず、App.vueの記載を全て削除し、以下のようにベース部分をコーディングしましょう。
機能は何もありませんが、起動すると静的な画面が表示されます。App.vue<template> <div> <h3>My TODO</h3> <input placeholder="Input here..."> <button >ADD</button> <h5>ToDo List</h5> <ul> <li> </li> </ul> </div> </template> <script> export default { data() { return { } }, methods: { } } </script>入力フォームの作成
TODOを追加するには、input要素に入力された値をjavascript側で操作できるようにする必要があります。
Vue.jsではその紐付けのことをバインディングと呼んでいます。
input要素に入力された値をバインディングするには、v-modelディレクティブを使用します。v-modelディレクティブについては、こちらを参照してください
ここでは変数名をnewtodoとしています。
template部分<input v-model="newTodo" placeholder="Input here...">合わせてscript部分のdataに変数newtodoを定義します。
return{}のなかに記載してください。
これで"newtodo"という変数のバインディングを定義したことになります。data()部分data() { return { newTodo: "" } }上記のように記載すると、input要素に入力された値をjavascript側で、"newTodo"という変数名で扱えるようになります。
TODOを格納する配列を定義
次にTODOの格納先を作成します。
今回は簡易的に配列に格納することにします。
Script部分のdata()にtodosという変数名で、空の配列を定義してください。
カンマ","を忘れないよう注意してください。data()部分data() { return { todos: [], newTodo: "" } }todosという変数とnewTodoという変数を定義したことになります。
クリックイベントの実装
TODOの追加方法ですが、ADDボタンが押されたら、配列に追加する仕様としましょう。
※このような仕様を説明する際は、ボタンのクリックイベントをトリガーにTODOの追加処理を行う、などと言います。Vueでクリックイベントの実装には、v-onディレクティブを使用します。
ここではクリック時にaddTodo()メソッドを実行するよう定義しています。template部分<button v-on:click="addTodo()">ADD</button>追加処理の実装
続いて、追加処理となるaddTodo()メソッドを定義します。
Vue.jsではmethods部分にメソッドを定義していきます。methods部分methods: { addTodo() { this.todos.push(this.newTodo); this.newTodo = ""; } }まず、配列に追加する値を取得します。
data部分に定義された変数を呼び出すにはthisを使います。
this.newTodo でフォームに入力された値を取得することができます。
また、格納先の配列もthisを使って記載します。追加処理は引数の値を配列に格納するpushメソッドを使用します。
以下のような実装をすることで、追加処理を行なっています。this.配列の変数名.push(this.追加対象の変数名)また、追加処理後に変数newTodoの値を空に設定します。
入力フォームの値をクリアしています。this.newTodo = "";※上記で説明したバインディングは、正確には双方向バインディングという機能です。
詳細はこちらを参照してくださいTODOの一覧表示
追加したTODOを一覧表示する機能を実装していきます。
方針としては配列に格納された値を取得し、繰り返し処理を実施して、各項目を表示していきます。
Vue.jsで繰り返し処理を行うには、v-forディレクティブを使用します。
templateのli要素の部分を修正してください。template部分<li v-for="(todo, i) in todos" v-bind:key="i"> {{ todo }} </li>
v-for="(todo, i) in todos"
と書くことで、配列todosから要素を1つ1つ取得し、todoという変数に格納しています。
{{ todo }}
で変数todoに格納された値を表示しています。
v-bind:key="i"
ではindex番号を格納する変数を定義しています。こちらは削除処理の際に、対象要素を指定するのに使用します。チェック処理の実装
ここまででTODOの追加処理を実装することができました。
しかし、入力フォームが空のままADDボタンを押して見てください。
TODO名が空の項目が追加されていると思います。
これは本来の用途に沿わないため、仕様として不適切です。これを回避するためチェック処理を実装します。
pushメソッドを実行する前に、以下のif文を追加してください。methods部分methods: { addTodo() { if (this.newTodo === "") return; this.todos.push(this.newTodo); this.newTodo = ""; } }this.newTodoの値が空の場合、addTodoメソッドを抜ける(returnする)処理を実装しています。
TODO削除機能の実装説明
クリックイベントの実装
追加と同様にv-onディレクティブを使用して、DELボタン押下をトリガーに、TODOを削除する仕様とします。
メソッド名deleteTodo(i)メソッド
引数のi
はindex番号が格納されています。template部分<li v-for="(todo, i) in todos" v-bind:key="i"> {{ todo }} <button v-on:click="deleteTodo(i)">DEL</button> </li>削除処理の実装
methodsにdeleteTodoメソッドを実装します。
配列から要素を削除するには、spliceメソッドを使用します。spliceメソッドは第一引数で開始位置を指定し、第二引数で削除する要素の個数を指定します。
spliceメソッドの説明開始位置はインデックス番号と同じですので、第一引数には
i
を指定し、削除個数は1要素だけですので、第二引数には1
を指定します。methods部分methods: { addTodo() { if (this.newTodo === "") return; this.todos.push(this.newTodo); this.newTodo = ""; }, deleteTodo(i) { this.todos.splice(i, 1); } }これで一通りのTODO追加、削除機能が実装できました。
終わりに
お疲れ様でした。
次回はデータベースやAPIサーバを利用した実装を紹介したいと思います。
- 投稿日:2020-07-05T13:35:40+09:00
Vue 3から素のemitが警告されるようになったので対処する
再現方法
親子関係のcomponentsにおいて、子から親に
emit
すること警告の内容
[Vue warn]: Extraneous non-emits event listeners (rewrite) were passed to component but could not be automatically inherited because component renders fragment or text root nodes.
If the listener is intended to be a component custom event listener only, declare it using the "emits" option. at ...コンポーネントがフラグメントまたはテキストのルートノードをレンダリングするため、外部の非エミッツイベントリスナー(書き換え)がコンポーネントに渡されましたが、自動的に継承することができませんでした。
リスナーがコンポーネントのカスタムイベントリスナーのみを意図している場合は、"emits "オプションを使用して宣言してください。
[翻訳] DeepL結論
子(emitする側)のコンポーネントで
emits
オプションを定義した上で、emit名を配列で宣言する。
以下のESLintルールに詳しい。
https://eslint.vuejs.org/rules/require-explicit-emits.html
このルールが追加された背景としてはコンポーネントからどんなイベントがemitされるのかを構造的に宣言できるようにすることでコードの自己文書化をしよう!みたいな意図だと理解した。詳細なコード
親で定義した
msg
変数を子のinputタグで書き換える単純なprops down, emits upの構成親コンポーネント
<template> <img alt="Vue logo" src="./assets/logo.png" /> <HelloWorld :msg="msg" @rewrite="changeMsg" /> </template> <script lang="ts"> import { defineComponent, ref } from 'vue' import HelloWorld from './components/HelloWorld.vue' export default defineComponent({ name: 'App', components: { HelloWorld, }, setup() { let msg = ref('Hello Vue 3.0 + Vite') const changeMsg = (e) => { msg.value = e.target.value } return { msg, changeMsg, } }, }) </script>子コンポーネント
<template> <h1>{{ msg }}</h1> <button @click="count++">count is: {{ count }}</button> <p> Edit <code>components/HelloWorld.vue</code> to test hot module replacement. </p> <input type="text" :value="msg" @input="changeMsg" /> </template> <script lang="ts"> import { defineComponent, ref } from 'vue' type Props = { msg: string } export default defineComponent({ name: 'HelloWorld', props: { msg: { type: String, default: 'default Value', }, }, emits: ['rewrite'], // このオプションが必要 setup(props: Props, { emit }) { const count = ref(0) const changeMsg = (e) => { emit('rewrite', e) } return { count, changeMsg, } }, }) </script>何が詰まったか
- emitsオプションを定義すべきことは分かるが、どういった形式で宣言すべきか、また何を宣言すべきかのヒントが無い
- 警告内容でググっても現時点ではこのルールそのもののPRしかヒットせず、どうすれば解決するかが掴めない(https://github.com/vuejs/vue-next/issues/1001)
- 純粋にググラビリティが低く、
vue 3 emits option
等検索しても2.x時代のドキュメントが大量にヒットしてしまうというわけで
謎に詰まってしまった。。。
おもむろに親子間でemitするだけで警告されるので、2.x時代からVueを書いている方はお気をつけください。
- 投稿日:2020-07-05T13:35:40+09:00
Vue 3で`Extraneous non-emits event listeners...`警告を回避するための方法
再現方法
親子関係のcomponentsにおいて、子から親に
emit
すること警告の内容
[Vue warn]: Extraneous non-emits event listeners (rewrite) were passed to component but could not be automatically inherited because component renders fragment or text root nodes.
If the listener is intended to be a component custom event listener only, declare it using the "emits" option. at ...コンポーネントがフラグメントまたはテキストのルートノードをレンダリングするため、外部の非エミッツイベントリスナー(書き換え)がコンポーネントに渡されましたが、自動的に継承することができませんでした。
リスナーがコンポーネントのカスタムイベントリスナーのみを意図している場合は、"emits "オプションを使用して宣言してください。
[翻訳] DeepL結論
子(emitする側)のコンポーネントで
emits
オプションを定義した上で、emit名を配列で宣言する。
以下のESLintルールに詳しい。
https://eslint.vuejs.org/rules/require-explicit-emits.html
このルールが追加された背景としてはコンポーネントからどんなイベントがemitされるのかを構造的に宣言できるようにすることでコードの自己文書化をしよう!みたいな意図だと理解した。詳細なコード
親で定義した
msg
変数を子のinputタグで書き換える単純なprops down, emits upの構成親コンポーネント
<template> <img alt="Vue logo" src="./assets/logo.png" /> <HelloWorld :msg="msg" @rewrite="changeMsg" /> </template> <script lang="ts"> import { defineComponent, ref } from 'vue' import HelloWorld from './components/HelloWorld.vue' export default defineComponent({ name: 'App', components: { HelloWorld, }, setup() { let msg = ref('Hello Vue 3.0 + Vite') const changeMsg = (e) => { msg.value = e.target.value } return { msg, changeMsg, } }, }) </script>子コンポーネント
<template> <h1>{{ msg }}</h1> <button @click="count++">count is: {{ count }}</button> <p> Edit <code>components/HelloWorld.vue</code> to test hot module replacement. </p> <input type="text" :value="msg" @input="changeMsg" /> </template> <script lang="ts"> import { defineComponent, ref } from 'vue' type Props = { msg: string } export default defineComponent({ name: 'HelloWorld', props: { msg: { type: String, default: 'default Value', }, }, emits: ['rewrite'], // このオプションが必要 setup(props: Props, { emit }) { const count = ref(0) const changeMsg = (e) => { emit('rewrite', e) } return { count, changeMsg, } }, }) </script>何が詰まったか
- emitsオプションを定義すべきことは分かるが、どういった形式で宣言すべきか、また何を宣言すべきかのヒントが無い
- 警告内容でググっても現時点ではこのルールそのもののPRしかヒットせず、どうすれば解決するかが掴めない(https://github.com/vuejs/vue-next/issues/1001)
- 純粋にググラビリティが低く、
vue 3 emits option
等検索しても2.x時代のドキュメントが大量にヒットしてしまうというわけで
謎に詰まってしまった。。。
おもむろに親子間でemitするだけで警告されるので、2.x時代からVueを書いている方はお気をつけください。
- 投稿日:2020-07-05T13:35:20+09:00
AWS Amplify(Cognito)でGoogleソーシャルログインする
Amplify
は、高速でアプリを開発できる開発プラットフォームです。
Amplify
で作成したVue.js
アプリで作成したもの
上のイメージの通りですが、Googleログイン済でない場合は、Googleへのログインを挟んでログイン処理を行います。
ソースコードの全量はGitHubにあります。
https://github.com/Thirosue/amplify-sns-fedaration前提
Amplify
の設定が完了していること(amplify configure
、amplify init
およびamplify push
が済んでいること)Amplify
で作成したVue.js
アプリが存在すること(aws-amplify
とaws-amplify-vue
の導入・設定が済んでいること)
Amplify
の状態は以下のとおり(amplify status
)$ amplify status Current Environment: prod | Category | Resource name | Operation | Provider plugin | | -------- | ------------- | --------- | --------------- |
Amplify
のバージョンはv4
系% amplify -v 4.21.3
Amplifyの設定はこちらを参照してください。
AWS 怠惰なプログラマ向けお手軽アプリ開発手法 2019
https://feature-webpush.dma9ecr5ksxts.amplifyapp.com/手順
Amplify CLI
でサポートされているので、手順はこれだけです。
- GCP側の設定
- 認証モジュールの追加(
amplify add auth
)- GCP側にリダイレクトURLの設定
1. GCP側の設定
Step1. GCPにログインします
Step2. 認証情報(
OAuth
クライアント)を作成しますStep2-1. 認証情報設定画面に移動します
「APIとサービス」-「認証情報」を選択して進みます。
Step2-2. 認証情報を作成します
「+ 認証情報を作成」-「OAuth クライアント ID」を選択します。
Step2-3. 認証情報の設定
- ウェブアプリケーションを選択
- 作成する
作成した「OAuth クライアント」の「クライアント ID」と「クライアント シークレット」は
2. 認証モジュールの追加
で利用するので、メモしておきましょう。2. 認証モジュールの追加(
amplify add auth
)認証機能をコマンドライン(
Amplify CLI
)で追加していきます。
以下コマンドで認証モジュール(Cognito
)を追加します。amplify add authStep1. Do you want to use the default authentication and security configuration? (Use arrow keys)
Default configuration with Social Provider (Federation)
を選択Do you want to use the default authentication and security configuration? Default configuration ❯ Default configuration with Social Provider (Federation) Manual configuration I want to learn more.Step2. How do you want users to be able to sign in? (Use arrow keys)
どれでもいいですが、
Username
を選択How do you want users to be able to sign in? (Use arrow keys) ❯ Username Email Phone Number Email or Phone Number I want to learn more.Step3. Do you want to configure advanced settings? (Use arrow keys)
デフォルトのまま進む
Do you want to configure advanced settings? (Use arrow keys) ❯ No, I am done. Yes, I want to make some additional changes.Step4. What domain name prefix do you want to use?
ドメインはデフォルトのまま
What domain name prefix do you want to use? (amplifysnsfedarationYYYYY-XXXXX)Step5. Enter your redirect signin URI:
ローカルで試すので、
http://localhost:8080/
を入力Enter your redirect signin URI: http://localhost:8080/Step6. Do you want to add another redirect signin URI (y/N)
ローカルで試すので、その他のリダイレクトURLは不要
Do you want to add another redirect signin URI (y/N) NStep7. Enter your redirect signout URI:
ローカルで試すので、
http://localhost:8080/
を入力Enter your redirect signout URI: http://localhost:8080/Step8. ? Do you want to add another redirect signout URI (y/N)
ローカルで試すので、その他のサインアウトURLは不要
? Do you want to add another redirect signout URI (y/N) NStep9. Select the social providers you want to configure for your user pool:
Select the social providers you want to configure for your user pool: ◯ Facebook ❯◉ Google ◯ Login With Amazon
Step10. Enter your Google Web Client ID for your OAuth flow:
1. GCP側の設定
で作成したアプリケーションの「クライアント ID」を入力Enter your Google Web Client ID for your OAuth flow: xxxxxxxxx
Step11. Enter your Google Web Client Secret for your OAuth flow:
1. GCP側の設定
で作成したアプリケーションの「クライアント シークレット」を入力Enter your Google Web Client Secret for your OAuth flow: yyyyyyyyyy
Step12. 設定の反映(AWSリソースの作成)
以下コマンドで設定を反映(AWSリソースの作成)します。
amplify push実行結果
正常に認証モジュールが追加されていることを以下コマンド(
amplify status
)で確認します。$ amplify status Current Environment: prod | Category | Resource name | Operation | Provider plugin | | -------- | ---------------------------- | --------- | ----------------- | | Auth | amplifysnsfedarationXXXXXXXX | No Change | awscloudformation |設定の全量
% amplify add auth Using service: Cognito, provided by: awscloudformation The current configured provider is Amazon Cognito. Do you want to use the default authentication and security configuration? Default configuration with Social Provider (Federation) Warning: you will not be able to edit these selections. How do you want users to be able to sign in? Username Do you want to configure advanced settings? No, I am done. What domain name prefix do you want to use? Enter your redirect signin URI: http://localhost:8080/ ? Do you want to add another redirect signin URI No Enter your redirect signout URI: http://localhost:8080/ ? Do you want to add another redirect signout URI No Select the social providers you want to configure for your user pool: Google You've opted to allow users to authenticate via Google. If you haven't already, you'll need to go to https://developers.google.com/identity and create an App ID. Enter your Google Web Client ID for your OAuth flow: xxxxxxxxxxxxxxxxxxxxxxxxxxxxx Enter your Google Web Client Secret for your OAuth flow: yyyyyyyyyyyyyyyyyyyyyyyyyyy Successfully added resource amplifysnsfedarationc74d4543 locally Some next steps: "amplify push" will build all your local backend resources and provision it in the cloud "amplify publish" will build all your local backend and frontend resources (if you have hosting category added) and provision it in the cloud3. GCP側にリダイレクトURLの設定
1. GCP側の設定
で作成した「認証情報(OAuth
クライアント)」にCognito
で作成された認証のリダイレクトURLを設定します。Step1.
Amplify
の設定ファイル(aws-exports.js
)を確認して、Cognito
のドメインを確認します。
2. 認証モジュールの追加
の結果作成された、Amplify
の設定ファイル(aws-exports.js
)のCognito
のドメインの箇所を確認します。% cat src/aws-exports.js | grep domain "domain": "amplifysnsfedarationyyyyyyy-xxxxxxx-prod.auth.ap-northeast-1.amazoncognito.com",Step2.
1. GCP側の設定
で作成した「認証情報(OAuth
クライアント)」に「認証のリダイレクトURL」を設定します。設定する値は
https://(Step1で確認したドメイン)/oauth2/idpresponse
です。
上の例の場合は、https://amplifysnsfedarationyyyyyyy-xxxxxxx-prod.auth.ap-northeast-1.amazoncognito.com/oauth2/idpresponse
となります。アプリケーションの修正
App.vue
を修正します。ログインに付随する細かい処理(ダイアログ出力、状態の管理(ローディング、サインイン)など)の説明は割愛します。
詳細は、全量のソースコード(GitHub)を確認してください。
https://github.com/Thirosue/amplify-sns-fedaration
html
の修正「Googleでログイン」のリンクを追加
<a href="#" @click="signIn('Google')">Googleでログイン</a>サインインで画面切り替え
サインイン状態(signedIn)で表示を切り替えます。
<!-- サインイン状態はstoreかdataに設定する --> <template v-if="signedIn"> <h1>Logged in</h1> <a href="#" @click="signOut"><font color="gray">Sign Out</font></a> </template> <template v-else> <amplify-authenticator v-bind:authConfig="authConfig" /> <ul> <li><a href="#" @click="signIn('Google')">Googleでログイン</a></li> </ul> </template>
method
の追加ログイン処理を追加します。
Vue.jsimport { Auth, Hub } from "aws-amplify"; //(...中略...) methods: { async signIn(provider) { this.$store.dispatch("loading", true); //処理中表示(処理開始) const res = await Auth.federatedSignIn({ provider }); console.log(res); },イベントフックを追加
Vue.jsimport { Auth, Hub } from "aws-amplify"; //(...中略...) async beforeCreate() { //サインインイベントフックを追加 Hub.listen("auth", async (data) => { switch (data.payload.event) { case "signIn": { // サインインイベントをフック const cognitoUser = await Auth.currentAuthenticatedUser(); console.log(`signed in ... ${cognitoUser.username}`); this.$store.dispatch("signedIn", true); this.$store.dispatch("loading", false); //処理中表示(処理終了) Swal.fire({ // ダイアログ表示 position: "top-end", icon: "success", title: "ログインしました", showConfirmButton: false, timer: 1500, }); break; } default: break; } }); },確認
アプリケーションの起動
以下コマンド(
yarn serve
)でアプリケーションを起動しますyarn servehttp://localhost:8080/へアクセスして確認します。
「Googleでログイン」リンクをクリックすると、Googleへログインした後、ログイン後状態に遷移します。
最後に
Amplify CLI
を利用すると容易にソーシャルログインが実現できます。
LINE
(OpenID)や参考リンク
Amplify関連
Googleとの連携設定
- 投稿日:2020-07-05T12:20:02+09:00
Vue.jsを使ってFirebaseのデータを削除する
Vue.jsを使ってFirebaseのデータベース firestore のデータを削除する方法をお伝えします。
概要確認
今回のゴールはHP上の×ボタンを押したらデータベース上のデータも連動して消えるです。
手順
このゴールを達成するためには以下の手順でコードを書いていきます。
- ×ボタンを押すとindexをトリガーに、その項目のidをゲットする
- その項目のidを変数に設定
- さらにその変数をトリガーにして、削除ボタンで項目削除
1. ×ボタンを押すとindexをトリガーに、その項目のidをゲットする
削除ボタンを押すとその項目のindexをゲットします。
book-management.vue<tr v-for="(book, index) in books" :key="book.bookId"> <td> <input v-model="book.title" type="text" /> </td> <td> <input v-model="book.type" type="text" /> </td> <!-- レンタルの可否 --> <td> <select v-model="book.rental"> <option v-if="book.rental === 'ok'" value="ok"> OK </option> <option v-else value="ng"> NG </option> </select> </td> <!-- 所在 --> <td> <input v-model="book.currentPlace" type="text" /> </td> <!-- レンタルボタン --> <td> <button v-if="book.rental === 'ok'" type="button" @click="onRentBookClick()" > 借りる </button> <p v-else-if="book.rental === 'ng' && book.currentPlace !== '自分'"> レンタル不可 </p> <button v-else type="button" @click="onReturnBookClick()"> 返す </button> </td> <!-- 所有者 --> <td> <input v-model="book.owner" type="text" /> </td> <!-- 削除ボタン --> <td> <button class="delete" @click="switchDelateAlarm(), getIndex(index)" > × </button> </td> </tr>上記の中にあるコチラ↓
book-management.vue<button class="delete" @click="switchDelateAlarm(), getIndex(index)"> × </button>2. その項目のidを変数に設定
ここから関数getIndex()に引数indexを渡します。
そして関数getIndex()はコチラ。
book-management.vuegetIndex(index) { this.delateId = this.books[index].id },これによりdelateIdが定義されました。
3. さらにその変数をトリガーにして、削除ボタンで項目削除
さて、下記の1番上の×ボタンを押します。
すると、このようなポップアップが出ます。
実は×ボタンを押すことで関数getIndex()以外にもうひとつ関数switchDelateAlarm()が起動するようになっています。
このポップアップはそれによるものです。
次はこのポップアップ上の削除ボタンを押すと実際に項目が削除されるようにしていきます。
下記がポップアップのtemplateです。
book-management.vue<div v-show="showDelateAlarm" id="overlay"> <div id="delateAlarm"> <p>この本の情報を削除します</p> <button @click="switchDelateAlarm"> 戻る </button> <button @click="switchDelateAlarm(), deleteItem(delateId)"> 削除 </button> </div> </div>関数deleteItem()の引数に先ほど定義した変数delateIdを渡していますね。
では関数deleteItem()の内容はというと……
book-management.vuedeleteItem(deleteId) { db.collection("books").doc(deleteId).delete() }こんな感じです。
まとめ
以上でfirestoreと連動した削除ボタンの完成です。
- 投稿日:2020-07-05T06:39:06+09:00
【Vue/Nuxt/Express】Universal Modeでメールアドレスのログイン機能を実装した
NuxtのUniversal Modeで開発しているのですが、ログイン機能を作るのに悪戦苦闘しました。
備忘録がてら実装をまとめてみたいと思います。
NuxtがフロントエンドでRailsがバックエンドになりますね。
Railsの認証機能はdevise_token_authを利用しています。
https://github.com/lynndylanhurley/devise_token_auth
※devise_token_authについては詳しく触れないので上記URLを見ていただけると幸いです。
ログイン機能の流れ
前提として、メールアドレスとパスワードを用いたよくあるログイン機能を実装を実装しています。
まずは、ログインの流れを説明します。
- ログインページにアクセス
- メールアドレスとパスワードを入力してAPIにPOST
- APIからログイン認証のTokenが返却される
- Storeに返却されたTokenを保存
- StoreからTokenを取り出してExpressにPOST
- ExpressでPOSTされたデータを受け取ってCookieを生成
- Expressからフロントエンドに対して何か適当にレスポンス
- Cookieがブラウザに保存される
- SSR時にサイトにアクセスする
- nuxtServerInitでCookieのデータを取得
- 取得したデータをVuexに保存する
この流れでログイン認証をしていると言う感じですね。
Nuxt.jsのUniversal Modeでの実装
サインインページ
methodsのみ抜粋
<script> export default { methods: { handleInput(name, value) { this[name] = value }, async handleSubmit() { const body = { email: this.email, password: this.password, } await this.signIn(body) await this.setTokenInCookie() // StoreのTokenをExpressにPOSTするactionsを呼び出す alert('ログインが完了しました') this.$router.push('/') }, ...mapActions('user', ['signIn']), ...mapActions('user', ['setTokenInCookie']), }, } </script>サインイン時のAPIに対するPOST
APIに対してPOSTするとTokenが返却されます。
export const actions = { signIn({ commit }, body) { return new Promise((resolve, reject) => { this.$axios .post('/auth/sign_in', body) .then((res) => commit('SET_USER_TOKEN', res)) .then((res) => resolve(res)) .catch((err) => { alert('ログインに失敗しました') reject(err) }) }) }, }返却されたTokenをStoreに保存
export const state = () => ({ userToken: {}, }) export const mutations = { SET_USER_TOKEN(state, val) { state.userToken = { accessToken: val.headers['access-token'], client: val.headers.client, uid: val.headers.uid, } }, }ExpressにStoreに保存されたTokenをPOST
export const actions = { setTokenInCookie() { this.$axios.post(process.env.HOST + '/api/cookie', { accessToken: this.state.user.userToken.accessToken, client: this.state.user.userToken.client, uid: this.state.user.userToken.uid, }) }, }※nuxt.config.jsの設定は省きました
ExpressでCookieを設定
const express = require('express') const cookieParser = require('cookie-parser') const app = express() app.use(express.json()) app.use(express.urlencoded({ extended: true })) app.use(cookieParser()) app.post('/cookie', (req, res) => { res.cookie('access-token', req.body.accessToken, { maxAge: 60 * 60 * 24 * 14, secure: process.env.HOST !== 'http://localhost:3333', // secure属性は開発環境はfalseにしておきましょう httpOnly: true, sameSite: 'strict', }) res.cookie('uid', req.body.uid, { maxAge: 60 * 60 * 24 * 14, secure: process.env.HOST !== 'http://localhost:3333', httpOnly: true, sameSite: 'strict', }) res.cookie('client', req.body.client, { maxAge: 60 * 60 * 24 * 14, secure: process.env.HOST !== 'http://localhost:3333', httpOnly: true, sameSite: 'strict', }) res.json({ message: 'success' }) // 何か適当にレスポンスしてあげてください }) module.exports = { path: '/api', handler: app, }これで一通りCookieは保存出来たので取り出すだけですね。
nuxtServerInitでCookieの中身を取り出す
export const actions = { async nuxtServerInit({ dispatch }, req) { const accessToken = req.app.$cookies.get('access-token') const client = req.app.$cookies.get('client') const uid = req.app.$cookies.get('uid') if (accessToken && client && uid) { await dispatch('user/setTokenInStore', { accessToken, client, uid, }) } }, }取り出した中身をStoreに保存
export const state = () => ({ userToken: {}, }) export const mutations = { SET_USER_TOKEN_SSR(state, val) { state.userToken = { accessToken: val.accessToken, client: val.client, uid: val.uid, } }, } export const actions = { setTokenInStore({ commit }, token) { commit('SET_USER_TOKEN_SSR', token) }, }
- 投稿日:2020-07-05T02:26:14+09:00
【絶対失敗しない】Vueで作るCSSナビゲーションメニューまとめ10選
こちらの記事に記載のデザイン・コードは全て自由に使っていただいて大丈夫です(筆者が作成したため)
プロジェクトに取り込んでより充実したデザインにしてもらえれば○
*動きだけ確認したい初学者の方はJSFiddle使ってみるといいですよ
ヘッダーデザインにコピペで使えるナビゲーションメニュー3選
動きは下の画像のような感じになります
1. ヘッダーで使いやすいシンプルなナビゲーションメニュー
2. ボタンが滑らかに拡大するかわいらしいナビゲーションメニュー
3. ボタンが縮小しながら立体的に浮き出るナビゲーションメニュー
4. ボタンが凹みながら縮小する超動くナビゲーションメニュー
コードを確認する
マウスオーバーでドロップダウンするナビゲーションメニュー3選
動きは下の画像のような感じになります
1. ドロップダウンするシンプルなナビゲーションメニュー
2. ボタンが浮く!独特でかわいいナビゲーションメニュー
3. マウスオーバーでドロップダウン!UIナビゲーションメニュー
コードを確認する
絶対失敗しないナビゲーションメニューサンプル3選
動きは下の画像のような感じになります
1. hoverするとボタンが浮き上がるナビゲーションメニュー
2. hoverするとボタンが凹むナビゲーションメニュー
3. hoverするとボタンが立体的に浮き出るナビゲーションメニュー
コードを確認する
- 投稿日:2020-07-05T02:26:14+09:00
【Vueエンジニアが作る】絶対失敗しないCSSナビゲーションメニューまとめ13選
こちらの記事に記載のデザイン・コードは全て自由に使っていただいて大丈夫です(筆者が作成したため)
プロジェクトに取り込んでより充実したデザインにしてもらえれば○
*動きだけ確認したい初学者の方はJSFiddle使ってみるといいですよ
ヘッダーデザインにコピペで使えるナビゲーションメニュー3選
動きは下の画像のような感じになります
1. ヘッダーで使いやすいシンプルなナビゲーションメニュー
2. ボタンが滑らかに拡大するかわいらしいナビゲーションメニュー
3. ボタンが縮小しながら立体的に浮き出るナビゲーションメニュー
4. ボタンが凹みながら縮小する超動くナビゲーションメニュー
コードを確認する
マウスオーバーでドロップダウンするナビゲーションメニュー3選
動きは下の画像のような感じになります
1. ドロップダウンするシンプルなナビゲーションメニュー
2. ボタンが浮く!独特でかわいいナビゲーションメニュー
3. マウスオーバーでドロップダウン!UIナビゲーションメニュー
コードを確認する
絶対失敗しないナビゲーションメニューサンプル3選
動きは下の画像のような感じになります
1. hoverするとボタンが浮き上がるナビゲーションメニュー
2. hoverするとボタンが凹むナビゲーションメニュー
3. hoverするとボタンが立体的に浮き出るナビゲーションメニュー
コードを確認する
初心者でも簡単!動きのあるナビゲーションバーデザイン3選
動きは下の画像のような感じになります
1. マウスオーバーで文字が太くなるナビゲーションバーデザイン
2. マウスオーバーで文字が傾くナビゲーションバーデザイン
3. transform: skew+hoverでシンプルなナビゲーションバーデザイン
コードを確認する
- 投稿日:2020-07-05T02:26:14+09:00
【絶対失敗しない】CSSナビゲーションメニューまとめ10選(Vueで使えるアニメーション)
こちらの記事に記載のデザイン・コードは全て自由に使っていただいて大丈夫です(筆者が作成したため)
プロジェクトに取り込んでより充実したデザインにしてもらえれば○
*動きだけ確認したい初学者の方はJSFiddle使ってみるといいですよ
ヘッダーデザインにコピペで使えるナビゲーションメニュー3選
動きは下の画像のような感じになります
1. ヘッダーで使いやすいシンプルなナビゲーションメニュー
2. ボタンが滑らかに拡大するかわいらしいナビゲーションメニュー
3. ボタンが縮小しながら立体的に浮き出るナビゲーションメニュー
4. ボタンが凹みながら縮小する超動くナビゲーションメニュー
コードを確認する
マウスオーバーでドロップダウンするナビゲーションメニュー3選
動きは下の画像のような感じになります
1. ドロップダウンするシンプルなナビゲーションメニュー
2. ボタンが浮く!独特でかわいいナビゲーションメニュー
3. マウスオーバーでドロップダウン!UIナビゲーションメニュー
コードを確認する
絶対失敗しないナビゲーションメニューサンプル3選
動きは下の画像のような感じになります
1. hoverするとボタンが浮き上がるナビゲーションメニュー
2. hoverするとボタンが凹むナビゲーションメニュー
3. hoverするとボタンが立体的に浮き出るナビゲーションメニュー
コードを確認する
- 投稿日:2020-07-05T00:54:12+09:00
2020年におけるVue.jsの勉強手順
Vue.jsを結構書く機会が多いのですが、振り返ってみると結構学び方が遠回りしていたなと感じたので、これからVue.jsやNuxt.jsを始める方におすすめの本とか紹介しておきます。
Vue.jsとNuxt.js
Nuxt.jsは何かというと、Vue.jsにVuexやVue Router等を設定してVue.jsを拡張したフレームワークです。
Vue.jsとNuxt.jsどっちを先に学ぶべきかというとNuxt.jsかなと思います。
Vue.jsはVuexやVue Routerが入っていないので、設定する際に迷ったりするかと思いますが、Nuxt.jsは最初からいい塩梅に設定されているんですよね。
なので、Nuxt.jsで慣れてからVue.jsで色々と設定した方が何をやっているかわかりやすいと思います。おすすめの書籍
最初にやる本はこちらをおすすめします。
チュートリアルのような本なので、とりあえず開発の基礎を学べます。
次に以下のどちらかをおすすめします。
これらはVue.jsについて詳しく解説しているのですが、その分最初からやり切るにはしんどいです。なので、最初の本で開発になれたら逆引きや知識を深めるのにこれらの本をおすすめします。これらは自分が読んでみた感想なので、異論は認めます。
- 投稿日:2020-07-05T00:21:48+09:00
Spotifyの歌詞カードっぽく画像に応じたいい感じの背景色と文字色を設定する(Vue.js)
SpotifyのUIかっこいい
SpotifyのUIが好きです。特にアルバム画像に合わせて歌詞カードや背景の色が変わる機能は地味に感動します。
直感で画像中に使われている色を上手いこと使って背景色と文字色に使ってるんだろうなーとは分かりますが、今回はこれをできる限りちゃんと理解して実装したいと思います。
jsやPHPでの先行事例
この機能がはじめに実装されたのはiTunesみたいです。
jsやPHPで同じようなことをしている事例はこちら
PHPで画像色抽出
iTunesみたいに再生中の曲のアートワーク(ジャケット写真)に合わせて背景色と文字色を変える
かっこよすぎる!JavaScriptで、画像に多く使われている色を背景色に設定しよう!サンプルコード付きライブラリ
画像から代表色を抽出してくれるライブラリは色々ありますが、今回はVibrant.jsのnpmパッケージであるnode-vibrantを使います。
ライブラリ間の比較はこちらでやってくれてます。
画像からメインカラーを取得するjavascriptライブラリの比較アルゴリズム
iTunes 11の曲リストに色を付けるアルゴリズムはどのように機能しますか?
が非常に参考になりました。だいたいやることとしては
1. 画像の読み込み
2. 代表色の抽出
3. 代表色の中で鮮やかな色を背景色として決定
4. 決定した背景色と他の代表色とを比較して、コントラストが一定値以上ならその色を文字色として決定、コントラストが十分でなければ白や黒を文字色として選ぶ
です。代表色の抽出
もっとも簡単で有名な手法はKmeansクラスタリングを使った手法(ex. 画像のドミナントカラーをk-meansクラスタリングで抽出)ですが、より高速な手法としてMMCQ (Modified median-cut color quantization)というものが提案されています。
もともと画像の減色処理をするために使われるメディアン・カット法というものを修正したアルゴリズムらしいのですが、どこらへんが修正されているのかまでは理解してません。メディアン・カット法についてはこちらの動画が非常に分かりやすいです。
Color Quantizationnode-vibrantもMMCQを使用して代表色抽出をしています。
鮮やかさの計算
node-vibrantではVibrant=鮮やかな色やMuted=くすんだ色をわざわざ自分で計算せずとも算出してくれるので今回は使いませんが、一応鮮やかさ colorfulness の定義を調べたので書いておきます。単純にHSVに変換してS(彩度)成分とればいいんじゃない?と思いますが、厳密には違うらしいです。Hasler and Süsstrunk’s, 2003で紹介されているように、反対色空間におけるRG成分とYB成分の平均と標準偏差を使った値を元に鮮やかさを定義します。
PythonとOpenCVによる実装と解説はこちらです。
Computing image “colorfulness” with OpenCV and Pythonこれは人間が色を知覚するときに反対色空間で認識していることが理由なのではと思っています。
ちなみにnode-vibrantではHSVのV(輝度)とS(彩度)に閾値を設定してそれぞれの代表色をVibrantやMutedに割り振っているみたいです。(ここら辺あんまり自信ないです)コントラストの計算
node-vibrantのgetTitleTextColorというメソッドを使えば白または黒のどちらか適した色を選んでくれるのですが、ただ白か黒を使うのも芸がないのでちゃんと自分で計算したいところです。W3Cの定義を使います。
contrast = (L_1 + 0.05) / (L_2 + 0.05), \\ここで、Lは相対輝度です。詳しくはリンク先を見て下さい。
実装
Nuxt.js + Pug + Stylus で実装しています。
まずは公式の指示通りnpmでパッケージを入れます。
npm install node-vibrantPug(HTML)部分
<template lang="pug"> .container .main(:style="{ background: background_color}") img(:src="url" :alt="url") ul.color li.color__item(v-for="(rgb, id) in model_rgb_colors" :key="id" :style="{ background: rgb}") .textbox(:style="{ color: txt_color}") h3 いい感じの色になるように p 抽出したカラーパレットを元にして p 文章の色を変えてみてます p 実装には p node-vibrantというパッケージを使いました </template>JS部分
<script> import Vibrant from 'node-vibrant'; export default { layout: 'wide', components: { Vibrant }, data(){ return{ model_colors: [], model_rgb_colors: [], background_color: "#fff", txt_color: "#000", url: "/test.jpg" } }, watch: { model_colors: function(val){ let arr = [] for (let i=0; i < val.length; i++){ let rgb = 'rgb(' + val[i][0] + ',' + val[i][1] + ',' + val[i][2] + ')' arr.push(rgb) } this.model_rgb_colors = arr this.background_color = this.model_rgb_colors[0] const limit_min = 2.0 // コントラストの閾値 let txt_color = null for (let j=1; j < val.length; j++){ let cont = this.calcContrast(val[0], val[j]) if(cont >= limit_min){ // コントラストが閾値以上ならテキストの色を決定 txt_color = this.model_rgb_colors[j] this.txt_color = txt_color break } } // 代表色を全て探索してコントラストが閾値以上になる色がなければ白か黒にする if(txt_color == null){ if(this.calcContrast(val[0], [255, 255, 255]) >= limit_min){ this.txt_color = "#fff" }else{ this.txt_color = "#000" } } } }, methods: { getColor: function (img_path) { let self = this Vibrant.from(img_path).getPalette() .then(function (palette) { let arr = Object.keys(palette).map(function (key) { return palette[key]._rgb }) self.model_colors = arr }); }, // 相対輝度 luminanace: function(r, g, b){ r /= 255 g /= 255 b /= 255 if(r <= 0.03928){ r /= 12.92 }else{ r = Math.pow((r + 0.055) / 1.055, 2.4) } return r * 0.2126 + g * 0.7152 + b * 0.0722 }, // コントラストの計算 calcContrast: function(rgb1, rgb2){ const lum1 = this.luminanace(rgb1[0], rgb1[1], rgb1[2]) const lum2 = this.luminanace(rgb2[0], rgb2[1], rgb2[2]) const brightest = Math.max(lum1, lum2) const darkest = Math.min(lum1, lum2) return (brightest + 0.05) / (darkest + 0.05) } }, created(){ this.getColor(this.url) } } </script>Stylus(CSS)部分
<style scoped lang="stylus"> .container width 100% display flex justify-content center align-items center background #fff .main width 800px display flex flex-direction column justify-content center align-items center padding 32px 0 border-radius 16px img width 400px height auto .color list-style none display flex flex-direction row justify-content center align-items center margin 8px 0 &__item width 40px height 40px border-radius 50% border 1px solid #fff margin 16px .textbox width 400px </style>static直下においたtest.jpgという画像を参照しています。
ここで、コントラストの閾値(=2.0)は経験的に決定しました。
また、Vibrantから返ってくる値は[255, 0, 0]のような形式なので、そのままスタイルバインディングしてもうまく背景色や文字色に適用されません。そのため、watchプロパティの中でrgb(255, 0, 0)のような形に変換しています。上のコードはこんな感じになります。まあまあうまく背景色と文字色を設定できているっぽいですね。
デモ
せっかくなのでvue-awesome-swiperと組み合わせてスライドの変更に応じて背景色と文字色が変わるようにしてみました。
DEMO結果
Spotify本家にどれくらい近づけたか見てみましょう。
Spotify APIを使って『Tokyo Super Hits!』というプレイリストからアルバム画像の一覧を取得してきます。
APIの使い方に関しては割愛します。
アルバムに['primary_color']という要素があるのを見るとSpotifyはリアルタイムで背景色を算出しているわけではなさそうですね。左列に今回の結果、中央列にSpotify上での表示、右列にAPIから取得した['primary_color']を載せています。
概ね一致していそうなのは50%くらいでしょうか。
一致していない色を見てみると、黄色や明るい水色ということから、本家では歌詞部分に白文字を使っているため白とのコントラストも計算して背景色を決定していると考察できます。
よって、6色でなくもっと多い代表色を抽出してきて、白とのコントラストを考慮した鮮やかな色を抽出すれば一致するかもしれません。
また、文字に関しては本家は背景色の彩度と明度を下げたような色を使っています。['primary_color']は背景色かなと思ってたのですが必ずしもそういうわけではないみたいですね。謎です。
Spotify API初めて使ったのですが色々遊べそうだったので挑戦してみたいです。