20200705のvue.jsに関する記事は19件です。

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>

スクリーンショット 2020-07-05 20.29.40.png
まあまあ普通に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)でオブジェクト型を配列形式に変換しています。
スクリーンショット 2020-07-05 20.52.20.png

arrの中はこのような感じになっています。1つの配列に7つの配列が格納されているのがわかるかと思います。
このような形にすることでfilter関数やらfind関数やらが使えるようになります。
そして先ほど配列形式に変換したarrをfilter関数を使用してvalue.namevue.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のみが抽出されました。
スクリーンショット 2020-07-05 21.07.56.png

{{ js[1].name }}[1]は何かどこから来たのかというと
スクリーンショット 2020-07-05 21.09.32.png
先ほどのスクリプトで上の画像のような形で配列を受け取ります。
objという配列を一件ずつ回して表示させています。
HTML上でvue.jsと表示させるにはobjの中に複数ある配列の中の1: Objectの中にname: vue.jsがあるため{{ js[1].name }}と書いてあげることで添字を直接指定して表示させています。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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>

スクリーンショット 2020-07-05 20.29.40.png
まあまあ普通に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)でオブジェクト型を配列形式に変換しています。
スクリーンショット 2020-07-05 20.52.20.png

arrの中はこのような感じになっています。1つの配列に7つの配列が格納されているのがわかるかと思います。
このような形にすることでfilter関数やらfind関数やらが使えるようになります。
そして先ほど配列形式に変換したarrをfilter関数を使用してvalue.namevue.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のみが抽出されました。
スクリーンショット 2020-07-05 21.07.56.png

{{ js[1].name }}[1]は何かどこから来たのかというと
スクリーンショット 2020-07-05 21.09.32.png
先ほどのスクリプトで上の画像のような形で配列を受け取ります。
objという配列を一件ずつ回して表示させています。
HTML上でvue.jsと表示させるにはobjの中に複数ある配列の中の1: Objectの中にname: vue.jsがあるため{{ js[1].name }}と書いてあげることで添字を直接指定して表示させています。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

画像と音声ファイルをその場で反映させる方法

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

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

音声ファイルをその場で反映させる方法

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

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

パスワードの強さを判定

過去に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文が判定する仕様。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AWS Amplify(Cognito)でLINEへソーシャルログインする

Amplifyは、高速でアプリを開発できる開発プラットフォームです。
Amplifyで作成したVue.jsアプリでLINEとのログイン連携を試したので、メモしておきます。

Googleとのログイン連携はこちら

作成したもの

LINEアカウントの入力部分は動画編集でトリム(削除)しています。

ezgif-1-b42325d989ca.gif

ソースコードの全量はGitHubにあります。
https://github.com/Thirosue/amplify-sns-fedaration

前提

  • Amplifyの設定が完了していること(amplify configureamplify initおよびamplify pushが済んでいること)
  • Amplifyで作成したVue.jsアプリが存在すること(aws-amplifyaws-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とのログイン連携の手順は以下のとおりです。

  1. LINE側(LINE Developers)の設定
  2. 認証モジュールの追加(amplify add auth
  3. CongnitoのLINE連携(OpenID Connect)設定
  4. LINE側(LINE Developers)にリダイレクトURLの設定

1. LINE側(LINE Developers)の設定

Step1. LINE Developers ( https://developers.line.biz/ja/ ) にログインします

スクリーンショット 2020-07-05 15.34.17.png

Step2. プロバイダーを作成します。

プロバイダーを作成します。

スクリーンショット 2020-07-05 15.38.06.png

Step3. チャンネルを作成します。

少しわかりづらいですが、「Create a LINE Login channel」を選択し、チャンネルを作成します。

スクリーンショット 2020-07-05 15.40.08.png

チャンネル名やメールアドレスなどの必須項目を入力し、作成します。

5.jpg

作成したチャンネルの「チャンネル ID(Channel ID)」と「チャンネル シークレット(Channel secret)」は3. Congnito のLINE連携(OpenID Connect)設定で利用するので、メモしておきましょう。

2. 認証モジュールの追加(amplify add auth

認証機能をコマンドライン(Amplify CLI)で追加していきます。
以下コマンドで認証モジュール(Cognito)を追加します。

amplify add auth

Step1. 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?

Emailの選択を外して、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) N

Step9. 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) N

Step11. Select the social providers you want to configure for your user pool: (Press <space> to select, <a> to toggle all, <i> to invert selection)

LINEOpenID 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のサービスページに移動します。

スクリーンショット 2020-07-05 16.12.00.png

Step2. ユーザプールを選択します。

「ユーザープールの管理」を選択し、

スクリーンショット 2020-07-05 16.12.35.png

2. 認証モジュールの追加で作成した「ユーザープール」を選択します。

対象の「ユーザープール」の名称は、Amplifyの状態(amplify status)で確認したリソース名(Resource name)で始まります。

6.jpg

Step3. 「ID プロバイダー」(OpenID Connect)を追加します。

スクリーンショット 2020-07-05 16.22.57.png

図のとおり入力します。

項目名 値          
プロバイダ名 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 

7.jpg

Step4. Step3で作成した「ID プロバイダー」(OpenID Connect)の属性マッピングを変更します。

2. 認証モジュールの追加で作成した「ユーザープール」はName属性が必須であるため、subNameのマッピングを追加します。

スクリーンショット 2020-07-05 16.49.27.png

  • (参考)「ユーザープール」の設定
 What attributes are required for signing up? Name

Step5. アプリクライアントの設定でLINEログインを有効化します。

作成されている2つのアプリクライアント(xxxxxxxxxxxxxx_app_clientおよびxxxxxxxxxxxxxx_app_clientWeb)に対し、LINE連携を有効化します(チェックして保存する)。

スクリーンショット 2020-07-05 16.54.36.png

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となります。

スクリーンショット 2020-07-05 17.03.53.png

アプリケーションの修正

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.js
import { Auth, Hub } from "aws-amplify";
//(...中略...)

  methods: {
    async signIn(provider) {
      this.$store.dispatch("loading", true); //処理中表示(処理開始)
      const res = await Auth.federatedSignIn({ provider });
      console.log(res);
    },

イベントフックを追加

Vue.js
import { 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 serve

http://localhost:8080/へアクセスして確認します。

「LINEでログイン」リンクをクリックすると、LINEへログインした後、ログイン後状態に遷移します。

ezgif-1-b42325d989ca.gif

参考リンク

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【GAS x Vue.js】JavaScript のみで今、家計簿をつくるとしたら【ハンズオン付き!】

「JavaScriptのみ」&「無料」&「サーバーレス」なスプレッドシートと連携した家計簿をつくる方法を考えてみました。
実際に家計簿アプリを作るハンズオン付きです!

なにを作ったの?

Web上でデータを登録すると、スプレッドシートに反映される家計簿アプリです。
実際のページはこちら。使い方は「家計簿アプリお試し方法」で説明します。

データ追加の他に、データ編集と

データ削除を行えます。

スプレッドシートは月ごとにシートで管理され、Webアプリと同じように収支の合計も確認できます。

また、カテゴリ別の支出も確認できます。

使用した技術

  • バックエンド
    • Google Apps Script (GAS)
  • フロントエンド
    • Vue.js / Vue Router / Vuex
    • Vuetify
    • axios

全体の構成はこんなイメージです。シンプル。

制作のポイント

GAS で REST API もどきを作った

GAS で受け付けることのできるリクエストは GETPOST の2種類だけです。(doGet, doPost 関数)
これでは REST API を作ることはできないので、
リクエスト内容にメソッドの文字列を入れることで擬似的に GET, POST, PUT, DELETE に対応させました!:v:

家計簿は月ごとにシートを分けた

:o: メリット

  • 指定年月のデータ取得時の実行コストが低くなる
  • データ数が増えても API が重くなりにくい
  • スプレッドシートの内容を確認しやすい

指定年月のシートのデータをすべて取得すればいいので、「データが指定年月のものであるか?」を確認する必要がなくなります。
そのため、データ数が多くなっても1枚のシートで管理するより重くなりにくいです。:muscle:

また、Webアプリ/スプレッドシートどちらからでも家計簿のデータを確認しやすいのが強みです。

:x: デメリット

  • データ年月の編集時の実行コストが高くなる
  • 月をまたいだデータの取得/集計などが困難になる

編集前後でデータの年月を変えると、
「編集前の年月シートから削除」→「編集後の年月シートに追加」
する必要があるので、コストが高くなってしまいます。(そんな編集をすることは滅多にないと思いますが…)

また、今回作った API の仕様だと、1年分のデータを取得するのに、12回 API を叩く必要があります。
Webアプリでは月ごとの表示しかしていませんが、より細かい集計などするには API の改修が必要そうです。:innocent:

家計簿アプリお試し方法

それでは、実際にこのアプリを試してみる方法を紹介します。
3ステップだけで完了します!:sparkles:

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部構成でお送りします!

内容結構長いので、記事の最後まで飛びたい方はこちらをクリック。

環境構築

開発環境

Node.jsYarn がインストールされている前提で進めます。
下記のバージョンと近いものか、高いものであれば基本動くと思います。

> node -v
v12.16.3

> yarn -v
v1.22.4

Vue 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 RouterVuex を追加したいので、マニュアルで進めます。
(上下でカーソル移動、エンターで決定できます)

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) n

ESLint の設定はエラー防止のみの「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」が表示されれば環境構築完了です!:sparkles:

この開発サーバーでは ホットリロード が有効なので、ファイル編集がすぐに反映されます。
以降はこのサーバーが起動している前提で進めて行きます。

現時点のソースコード一覧はこちらから確認できます!

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>

上部に緑色のツールバーが表示されました。
toolbar-min.png

ツールバーに表示されたボタンを押すと画面が切り替わると思います。
これは、v-btnto 属性を設定すると、ボタンが押されたときにそのパスへ移動できるからです。

また、v-iconMaterial 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>

ルーティングの設定を変えて、HomeSettings が表示されるようにします。

router/index.js
import 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>

こんな感じになればOKです。

…いきなり長いコードになってしまいました。:bow:
重要だと思うところを説明します。

検索フォームでは 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 内の templatev-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 を作成します。
コードは少なめです:smile:

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>カンマ( &#44; )区切りで入力してください。</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.js
import 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 でアクセスでき、
ストアから stategetters にアクセスできます。

// Stateのsettingsにアクセス
this.$store.state.settings

フォームの内容を書き換えるのと同時に State も書き換わるは困るので、
一度 settings の内容をコピーして使用するようにしています。

/** 設定 */
settings: { ...this.$store.state.settings }

Actionsdispatch メソッドで実行できます。

// 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 // 短い

現時点のソースコード一覧はこちらから確認できます!

家計簿アプリの動作を実装してみる

それでは、フロント実装最後の仕上げに入っていきます!:sparkles:
家計簿データを追加/編集/削除できるようにします。

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 でフロント実装してみる」は以上になります。
お疲れ様でした!:tada: :beer:

現時点のソースコード一覧はこちらから確認できます!

Google Apps Script で REST API もどきを作ってみる

こちらから GAS で API の作成になります!!

「こだわりポイント」でも触れましたが、擬似的にメソッドを指定して
GET で取得、POST で追加、PUT で更新、DELETE で削除できる API を作成します。

シート準備

まずはじめにシートの準備をします。
Google スプレッドシートで新しいシートを作成して、「ツール」タブ→「スクリプトエディター」をクリックします。

↓が表示されていることを確認します。

もし表示されていなかった場合は、「実行」タブから V8 ランタイムを有効にしてください。

プロジェクトの名前を「家計簿API」と保存して、コード.gsapi.gs にリネームします。
api.gs の内容を書き換えます。

api.gs
const ss = SpreadsheetApp.getActive()

function test () {
  console.log(ss.getName())
}

メニューで「test」が選択されていることを確認してから
:arrow_forward: ボタンをクリックします。

「Authorization Required」というダイアログが表示されるので、
「許可を確認」ボタンをクリックしたあと、スプレッドシートを作成したアカウントでログインして「許可」ボタンをクリックします。

Ctrl + Enter (mac は Command + Enter) でログを確認できます。
作成したシートの名前が表示されればOKです。

家計簿のテンプレートをつくる

まずはじめに、家計簿のテンプレートとなるシートを作成する関数 insertTemplate を作ります。
シートのイメージを大雑把にまとめると

A1:B4 に収支確認エリア

A6:H6 にテーブルのヘッダー

J1:L1 にカテゴリ別支出のヘッダー

です。これをプログラムに落とし込みます。

api.gs
const 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.gs
const 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 は UtilitiesgetUuid を利用して UUID の先頭8文字だけ切り取るという謎のプログラムで生成しています。
公式リファレンスで使える便利メソッドが記載されているので、ぜひ確認してみてください。

const id = Utilities.getUuid().slice(0, 8)

この状態で test を実行してみます。
シートが新しく作成され、データの追加を確認してください!

データ取得する onGet をつくる

追加ができたら、次は取得してみたいですね。onGet を作ります。

api.gs
const 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.gs
const 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.gs
const 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 }
}

/** ~ 省略 ~ */

編集だけ「更新前と後で年月が違う場合」を考慮しないといけません。
削除と追加の処理は onDeleteonPost に任せます。

// 更新前と後で年月が違う場合、データ削除と追加を実行
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 完成です!! :sparkles: :sparkles:

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 もどきを作ってみる」は以上になります。
お疲れ様でした!:tada: :beer:

現時点のソースコード一覧はこちらから確認できます!

作った 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.js
import 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.js
import 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.vue
export 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,
  }),

  /** ~ 省略 ~ */
},

fetchAbDataPromise を返すようにしたので 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>

スナックバーは画面下に表示される、通知のようなものです

watcherrorMessage を監視して、変更のあったタイミングでスナックバーを表示させます。
スナックバーは一定時間経過すると自動で消えます。

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 })
    }
  },
  /** ~ 省略 ~ */
}
/** ~ 省略 ~ */

ItemDialogasync/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です!スプレッドシートも確認してみてください。

ハンズオンは以上になります。お疲れ様でした!:tada: :beer:

ホーム画面で収支の総計を確認できるようにしたり、毎月1日に先月の収入を自動で繰り越す GAS プログラムを追加したり…。

フロントに限らず、GAS 側も自分好みにしてみてください!

ハンズオン完成時点のソースコード一覧はこちらから確認できます!

さいごに

Vue.js の勉強用に作成したものなので、
改善できるところなどありましたらコメントで教えていただけると嬉しいです!

ハンズオンを最後まで進めていただいた方、上から飛んできた方も
最後まで閲覧いただきありがとうございました!:bow:

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.jsの勉強に利用したサイト

Vuexのかなりわかりやすい図解

https://qiita.com/m_mitsuhide/items/f16d988ec491b7800ace

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【初学者向け】Vue CLIを使ってTODOアプリを作る

はじめに

作成するアプリについて

本記事ではVue CLIを使って、簡単なTODOアプリを作成していきます。
機能としては以下の通りです。

  • TODO一覧の表示
  • TODO追加
  • TODO削除

Untitled.gif

対象読者
- 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

以降もいくつか選択を求められますが、全てデフォルトで大丈夫です。
全て選択するとプロジェクトの作成が始まります。

スクリーンショット 2020-07-04 18.02.11.png

プロジェクトの作成が完了すると以下の画面になります。
スクリーンショット 2020-07-04 18.03.45.png

サンプルアプリの起動

それでは動作確認のためアプリを起動してみましょう。
cdコマンドでプロジェクトルートディレクトリに移動します。

Desktop $ cd todo-app
todo-app $ 

起動コマンドを叩きます。

todo-app $ npm run serve

起動コマンドを実行すると、ビルドが開始されます。
スクリーンショット 2020-07-04 18.36.59.png

以下の画面が表示されれば、アプリの起動は完了です。
スクリーンショット 2020-07-04 18.37.15.png

では、実際にアクセスしてみましょう。
任意のブラウザでhttp://localhost:8080/にアクセスしてください。
以下の画面が表示されるかと思います。
スクリーンショット 2020-07-04 20.19.54.png

これで動作確認は完了です。
アプリの起動を停止しましょう。
ctrl + cで停止させてください。
スクリーンショット 2020-07-04 20.29.28.png

TODOアプリの確認

では、TODOアプリを作成していきましょう。
作成したプロジェクトをVisual Studio Codeで開いてください。
スクリーンショット 2020-07-04 18.16.52.png

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内でターミナルを起動することも可能です。
上部メニューのターミナル>新しいターミナルから起動してください。

スクリーンショット 2020-07-04 20.44.07.png

起動コマンドは同じです。
スクリーンショット 2020-07-04 20.44.36.png

起動後、再度"http://localhost:8080/"にアクセスしてください。
以下のような画面になっているかと思います。
スクリーンショット 2020-07-04 20.57.08.png

入力フォームに適当な値を入力して、ADDボタンを押してみてください。
スクリーンショット 2020-07-04 20.59.26.png

ToDo Listに項目が追加されるかと思います。
スクリーンショット 2020-07-04 21.03.02.png

こちらが今回作成するアプリになります。
ここではアプリのイメージを掴むため、コピペしていただきましたが、以降では順を追って実装内容を説明していきます。
初学者の方は、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サーバを利用した実装を紹介したいと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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>

何が詰まったか

  1. emitsオプションを定義すべきことは分かるが、どういった形式で宣言すべきか、また何を宣言すべきかのヒントが無い
  2. 警告内容でググっても現時点ではこのルールそのもののPRしかヒットせず、どうすれば解決するかが掴めない(https://github.com/vuejs/vue-next/issues/1001)
  3. 純粋にググラビリティが低く、vue 3 emits option等検索しても2.x時代のドキュメントが大量にヒットしてしまう

というわけで

謎に詰まってしまった。。。
おもむろに親子間でemitするだけで警告されるので、2.x時代からVueを書いている方はお気をつけください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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>

何が詰まったか

  1. emitsオプションを定義すべきことは分かるが、どういった形式で宣言すべきか、また何を宣言すべきかのヒントが無い
  2. 警告内容でググっても現時点ではこのルールそのもののPRしかヒットせず、どうすれば解決するかが掴めない(https://github.com/vuejs/vue-next/issues/1001)
  3. 純粋にググラビリティが低く、vue 3 emits option等検索しても2.x時代のドキュメントが大量にヒットしてしまう

というわけで

謎に詰まってしまった。。。
おもむろに親子間でemitするだけで警告されるので、2.x時代からVueを書いている方はお気をつけください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AWS Amplify(Cognito)でGoogleソーシャルログインする

Amplifyは、高速でアプリを開発できる開発プラットフォームです。
Amplifyで作成したVue.jsアプリでGoogleとのログイン連携を試したので、メモしておきます。

作成したもの

ezgif-1-2260dc7f05e4.gif

上のイメージの通りですが、Googleログイン済でない場合は、Googleへのログインを挟んでログイン処理を行います。

スクリーンショット 2020-07-05 9.05.55.png

ソースコードの全量はGitHubにあります。
https://github.com/Thirosue/amplify-sns-fedaration

前提

  • Amplifyの設定が完了していること(amplify configureamplify initおよびamplify pushが済んでいること)
  • Amplifyで作成したVue.jsアプリが存在すること(aws-amplifyaws-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/

手順

Googleとのログイン連携はAmplify CLIでサポートされているので、手順はこれだけです。

  1. GCP側の設定
  2. 認証モジュールの追加(amplify add auth
  3. GCP側にリダイレクトURLの設定

1. GCP側の設定

Step1. GCPにログインします

1.jpg

Step2. 認証情報(OAuthクライアント)を作成します

Step2-1. 認証情報設定画面に移動します

「APIとサービス」-「認証情報」を選択して進みます。

スクリーンショット 2020-07-05 10.13.42.png

Step2-2. 認証情報を作成します

「+ 認証情報を作成」-「OAuth クライアント ID」を選択します。

スクリーンショット 2020-07-05 10.18.59.png

Step2-3. 認証情報の設定

  • ウェブアプリケーションを選択

スクリーンショット 2020-07-05 10.21.42.png

  • 作成する

スクリーンショット 2020-07-05 10.23.37.png

作成した「OAuth クライアント」の「クライアント ID」と「クライアント シークレット」は2. 認証モジュールの追加で利用するので、メモしておきましょう。

2.jpg

2. 認証モジュールの追加(amplify add auth

認証機能をコマンドライン(Amplify CLI)で追加していきます。
以下コマンドで認証モジュール(Cognito)を追加します。

amplify add auth

Step1. 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) N

Step7. 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) N

Step9. Select the social providers you want to configure for your user pool:

Googleを選択

 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 cloud

3. 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となります。

4.jpg

アプリケーションの修正

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.js
import { Auth, Hub } from "aws-amplify";
//(...中略...)

  methods: {
    async signIn(provider) {
      this.$store.dispatch("loading", true); //処理中表示(処理開始)
      const res = await Auth.federatedSignIn({ provider });
      console.log(res);
    },

イベントフックを追加

Vue.js
import { 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 serve

http://localhost:8080/へアクセスして確認します。

「Googleでログイン」リンクをクリックすると、Googleへログインした後、ログイン後状態に遷移します。

ezgif-1-2260dc7f05e4.gif

最後に

Amplify CLIを利用すると容易にソーシャルログインが実現できます。
LINE(OpenID)やFaceBookへの連携も容易に実現でき、「モバイルアプリケーションとウェブアプリケーションを最速で構築する方法 」の説明は伊達ではないですね。

参考リンク

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.jsを使ってFirebaseのデータを削除する

Vue.jsを使ってFirebaseのデータベース firestore のデータを削除する方法をお伝えします。

概要確認

こんな感じのfirestore のデータベースがあります。
スクリーンショット 2020-07-05 11.48.46.png

このときHPはこんな感じです。
スクリーンショット 2020-07-05 11.47.26.png

今回のゴールはHP上の×ボタンを押したらデータベース上のデータも連動して消えるです。

手順

このゴールを達成するためには以下の手順でコードを書いていきます。

  1. ×ボタンを押すとindexをトリガーに、その項目のidをゲットする
  2. その項目のidを変数に設定
  3. さらにその変数をトリガーにして、削除ボタンで項目削除

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.vue
    getIndex(index) {
      this.delateId = this.books[index].id
    },

これによりdelateIdが定義されました。

3. さらにその変数をトリガーにして、削除ボタンで項目削除

さて、下記の1番上の×ボタンを押します。

スクリーンショット 2020-07-05 11.47.26.png

すると、このようなポップアップが出ます。

スクリーンショット 2020-07-05 12.07.54.png

実は×ボタンを押すことで関数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.vue
    deleteItem(deleteId) {
      db.collection("books").doc(deleteId).delete()
    }

こんな感じです。

まとめ

以上でfirestoreと連動した削除ボタンの完成です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【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を見ていただけると幸いです。

ログイン機能の流れ

前提として、メールアドレスとパスワードを用いたよくあるログイン機能を実装を実装しています。

まずは、ログインの流れを説明します。

  1. ログインページにアクセス
  2. メールアドレスとパスワードを入力してAPIにPOST
  3. APIからログイン認証のTokenが返却される
  4. Storeに返却されたTokenを保存
  5. StoreからTokenを取り出してExpressにPOST
  6. ExpressでPOSTされたデータを受け取ってCookieを生成
  7. Expressからフロントエンドに対して何か適当にレスポンス
  8. Cookieがブラウザに保存される
  9. SSR時にサイトにアクセスする
  10. nuxtServerInitでCookieのデータを取得
  11. 取得したデータを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)
  },
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【絶対失敗しない】Vueで作るCSSナビゲーションメニューまとめ10選

how-to-make-navigation.png

こちらの記事に記載のデザイン・コードは全て自由に使っていただいて大丈夫です(筆者が作成したため)
プロジェクトに取り込んでより充実したデザインにしてもらえれば○
*動きだけ確認したい初学者の方はJSFiddle使ってみるといいですよ


ヘッダーデザインにコピペで使えるナビゲーションメニュー3選

動きは下の画像のような感じになります

1. ヘッダーで使いやすいシンプルなナビゲーションメニュー

header-design-navigation-animation1.png

2. ボタンが滑らかに拡大するかわいらしいナビゲーションメニュー

header-design-navigation-animation2.png

3. ボタンが縮小しながら立体的に浮き出るナビゲーションメニュー

header-design-navigation-animation3.png

4. ボタンが凹みながら縮小する超動くナビゲーションメニュー

header-design-navigation-animation4.png

:point_down:コードを確認する

スクリーンショット 2020-07-04 19.09.11.png


マウスオーバーでドロップダウンするナビゲーションメニュー3選

動きは下の画像のような感じになります

1. ドロップダウンするシンプルなナビゲーションメニュー

hover-animation-navigation-dropdown1.png

2. ボタンが浮く!独特でかわいいナビゲーションメニュー

matereal-design-navigation1.png

3. マウスオーバーでドロップダウン!UIナビゲーションメニュー

hover-animation-navigation-dropdown3.png

:point_down:コードを確認する

スクリーンショット 2020-07-04 19.10.03.png


絶対失敗しないナビゲーションメニューサンプル3選

動きは下の画像のような感じになります

1. hoverするとボタンが浮き上がるナビゲーションメニュー

matereal-design-navigation1.png

2. hoverするとボタンが凹むナビゲーションメニュー

matereal-design-navigation2.png

3. hoverするとボタンが立体的に浮き出るナビゲーションメニュー

matereal-design-navigation3.png

:point_down:コードを確認する

スクリーンショット 2020-07-04 19.11.41.png

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Vueエンジニアが作る】絶対失敗しないCSSナビゲーションメニューまとめ13選

how-to-make-navigation.png

こちらの記事に記載のデザイン・コードは全て自由に使っていただいて大丈夫です(筆者が作成したため)
プロジェクトに取り込んでより充実したデザインにしてもらえれば○
*動きだけ確認したい初学者の方はJSFiddle使ってみるといいですよ


ヘッダーデザインにコピペで使えるナビゲーションメニュー3選

動きは下の画像のような感じになります

1. ヘッダーで使いやすいシンプルなナビゲーションメニュー

header-design-navigation-animation1.png

2. ボタンが滑らかに拡大するかわいらしいナビゲーションメニュー

header-design-navigation-animation2.png

3. ボタンが縮小しながら立体的に浮き出るナビゲーションメニュー

header-design-navigation-animation3.png

4. ボタンが凹みながら縮小する超動くナビゲーションメニュー

header-design-navigation-animation4.png

:point_down:コードを確認する

スクリーンショット 2020-07-04 19.09.11.png


マウスオーバーでドロップダウンするナビゲーションメニュー3選

動きは下の画像のような感じになります

1. ドロップダウンするシンプルなナビゲーションメニュー

hover-animation-navigation-dropdown1.png

2. ボタンが浮く!独特でかわいいナビゲーションメニュー

matereal-design-navigation1.png

3. マウスオーバーでドロップダウン!UIナビゲーションメニュー

hover-animation-navigation-dropdown3.png

:point_down:コードを確認する

スクリーンショット 2020-07-04 19.10.03.png


絶対失敗しないナビゲーションメニューサンプル3選

動きは下の画像のような感じになります

1. hoverするとボタンが浮き上がるナビゲーションメニュー

matereal-design-navigation1.png

2. hoverするとボタンが凹むナビゲーションメニュー

matereal-design-navigation2.png

3. hoverするとボタンが立体的に浮き出るナビゲーションメニュー

matereal-design-navigation3.png

:point_down:コードを確認する

スクリーンショット 2020-07-04 19.11.41.png


初心者でも簡単!動きのあるナビゲーションバーデザイン3選

動きは下の画像のような感じになります

1. マウスオーバーで文字が太くなるナビゲーションバーデザイン

navigation-bar-animation1.png

2. マウスオーバーで文字が傾くナビゲーションバーデザイン

navigation-bar-animation2.png

3. transform: skew+hoverでシンプルなナビゲーションバーデザイン

navigation-bar-animation3.png

:point_down:コードを確認する

スクリーンショット 2020-07-06 14.54.02.png

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【絶対失敗しない】CSSナビゲーションメニューまとめ10選(Vueで使えるアニメーション)

how-to-make-navigation.png

こちらの記事に記載のデザイン・コードは全て自由に使っていただいて大丈夫です(筆者が作成したため)
プロジェクトに取り込んでより充実したデザインにしてもらえれば○
*動きだけ確認したい初学者の方はJSFiddle使ってみるといいですよ


ヘッダーデザインにコピペで使えるナビゲーションメニュー3選

動きは下の画像のような感じになります

1. ヘッダーで使いやすいシンプルなナビゲーションメニュー

header-design-navigation-animation1.png

2. ボタンが滑らかに拡大するかわいらしいナビゲーションメニュー

header-design-navigation-animation2.png

3. ボタンが縮小しながら立体的に浮き出るナビゲーションメニュー

header-design-navigation-animation3.png

4. ボタンが凹みながら縮小する超動くナビゲーションメニュー

header-design-navigation-animation4.png

:point_down:コードを確認する

スクリーンショット 2020-07-04 19.09.11.png


マウスオーバーでドロップダウンするナビゲーションメニュー3選

動きは下の画像のような感じになります

1. ドロップダウンするシンプルなナビゲーションメニュー

hover-animation-navigation-dropdown1.png

2. ボタンが浮く!独特でかわいいナビゲーションメニュー

matereal-design-navigation1.png

3. マウスオーバーでドロップダウン!UIナビゲーションメニュー

hover-animation-navigation-dropdown3.png

:point_down:コードを確認する

スクリーンショット 2020-07-04 19.10.03.png


絶対失敗しないナビゲーションメニューサンプル3選

動きは下の画像のような感じになります

1. hoverするとボタンが浮き上がるナビゲーションメニュー

matereal-design-navigation1.png

2. hoverするとボタンが凹むナビゲーションメニュー

matereal-design-navigation2.png

3. hoverするとボタンが立体的に浮き出るナビゲーションメニュー

matereal-design-navigation3.png

:point_down:コードを確認する

スクリーンショット 2020-07-04 19.11.41.png

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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について詳しく解説しているのですが、その分最初からやり切るにはしんどいです。なので、最初の本で開発になれたら逆引きや知識を深めるのにこれらの本をおすすめします。

これらは自分が読んでみた感想なので、異論は認めます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Spotifyの歌詞カードっぽく画像に応じたいい感じの背景色と文字色を設定する(Vue.js)

SpotifyのUIかっこいい

SpotifyのUIが好きです。特にアルバム画像に合わせて歌詞カードや背景の色が変わる機能は地味に感動します。

歌詞カードの色が変わるとは、例えば瑛人の『香水』ならこう。
lyrics1.png

YOASOBI 『夜に駆ける』
lyrics2.png

ジェニーハイ 『ジェニーハイラプソディー』
lyrics3.png

直感で画像中に使われている色を上手いこと使って背景色と文字色に使ってるんだろうなーとは分かりますが、今回はこれをできる限りちゃんと理解して実装したいと思います。

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 Quantization

node-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-vibrant

Pug(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)のような形に変換しています。

上のコードはこんな感じになります。まあまあうまく背景色と文字色を設定できているっぽいですね。
スクリーンショット 2020-07-04 22.19.56.png

デモ

せっかくなのでvue-awesome-swiperと組み合わせてスライドの変更に応じて背景色と文字色が変わるようにしてみました。
DEMO

結果

Spotify本家にどれくらい近づけたか見てみましょう。
Spotify APIを使って『Tokyo Super Hits!』というプレイリストからアルバム画像の一覧を取得してきます。
APIの使い方に関しては割愛します。
アルバムに['primary_color']という要素があるのを見るとSpotifyはリアルタイムで背景色を算出しているわけではなさそうですね。

左列に今回の結果、中央列にSpotify上での表示、右列にAPIから取得した['primary_color']を載せています。
グループ 142.jpg

概ね一致していそうなのは50%くらいでしょうか。
一致していない色を見てみると、黄色や明るい水色ということから、本家では歌詞部分に白文字を使っているため白とのコントラストも計算して背景色を決定していると考察できます。
よって、6色でなくもっと多い代表色を抽出してきて、白とのコントラストを考慮した鮮やかな色を抽出すれば一致するかもしれません。
また、文字に関しては本家は背景色の彩度と明度を下げたような色を使っています。

['primary_color']は背景色かなと思ってたのですが必ずしもそういうわけではないみたいですね。謎です。
Spotify API初めて使ったのですが色々遊べそうだったので挑戦してみたいです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む