Vue.jsとFirebase/Firestoreを使った家族用ToDoリスト

#家族用ToDoリスト
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/352532/dbb6e049-edb0-3d30-43a3-dc169bbfd645.gif" width=50%>
Vue.jsの学習のために作成したので、その過程をまとめます。

学習用の成果物としてToDoリストは定番ですが、せっかくだから実生活で活用したいと思い、
家族、すなわち**事前に登録した特定のユーザのみで閉鎖的に利用できる**ToDoリストを作りました。
（自分だけのToDo管理なら普通にKeepメモとか使いますしね）

まずは「**1.ToDoリストの作成**」でFirestoreを利用したToDoリストを作成し、
その後「**2.認証機能の作成**」で家族用に仕上げます。

※VuetifyとVue routerが導入済みであることを前提とします。
＜参考＞ [Vuetifyの使い方：Vueプロジェクトへのインストール手順](https://dev83.com/vue-vuetify-install/)
＜参考＞ [インストール | Vue Router](https://dev83.com/vue-vuetify-install/)

#1.ToDoリストの作成

ありがたいことに、世の中ではいくらでもToDoリストの作り方が紹介されており
私のような**COBOLしか読めない人間**でも簡単にToDoリストを作ることができます。

ToDoの新規作成、読み取り、更新、削除機能を順に作っていきます。

##1.1 ToDoの新規作成
まず最初に入力フォームやボタンなどの外見を作成し、次にFirestoreにToDoを保存する機能を作成します。


###1.1-1) ToDo入力フォーム
ToDo名称を入力するためのフォームです。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/352532/0895a1b0-420f-46b0-514a-4d2603fb5643.png" width=50%>

```vuejs:ToDo入力フォーム抜粋
<v-text-field
 color="grey darken-1"
 label="ToDoを入力してください"
 v-model="newTodoName">
</v-text-field>
```
入力した値をあとでToDoタイトルとして使いたいので、変数`newTodoName`にバインドしています。


###1.1-2) 期限入力フォーム
期限がないと一生やらないので、各ToDoに期限を設定できるようにします。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/352532/d795844a-1ad8-c1aa-412c-89f2e9887c7f.png" width=50%>


[VuetifyのDatePicker](https://vuetifyjs.com/en/components/date-pickers/)で実装します。（正直コピペです…）
このパーツだけで記述量がそこそこになってしまうのと、今後何かで再利用することもあろうと考えてコンポーネント化しています。

```vue:datePicker.vue
<template>
  <v-row>
    <v-col cols="5" sm="6" md="4">
      <v-menu
        v-model="menu"
        :close-on-content-click="false"
        :nudge-right="40"
        transition="scale-transition"
        offset-y
        min-width="290px"
      >
        <template v-slot:activator="{ on, attrs }">
          <v-text-field
            v-model="date"
            label="todo期限"
            prepend-inner-icon="event"
            readonly
            v-bind="attrs"
            v-on="on"
          ></v-text-field>
        </template>
        <v-date-picker
         v-model="date"
         @input="menu = false"
         @change="datePick">
        </v-date-picker>
      </v-menu>
    </v-col>
  </v-row>
</template>

<script>
  export default {
    name: 'datepicker',
    data () {
      return {
      date: new Date().toISOString().substr(0, 10),
      menu: false,
      }
    },
    created:function(){
      //date初期値（当日の日付）を親コンポーネントにわたすためにemit実行
      this.$emit('datePick',this.date)
    },
    methods:{
      datePick(){
        this.$emit('datePick',this.date)
      }
    }
  }
</script>
```
日付が選択された際に日付を親コンポーネントに渡すために、`@change`で`$emit`を実行します。
加えて、日付は初期値でいい場合（当日をそのまま期限として設定したい場合）もあるので、createdフックでも実行しています。
今思えば、初期日付はブランクにして、必ず日付選択させるようにしてもよかったです。

なお、当コンポーネントは日付を`YYYY/MM/DD`の形式で渡しますが、今回のToDoリスト的には本来`MM/DD`で足ります。
しかし、再利用することも考えて汎用性のためにYYYYも含めた値を渡すようにしておき、親側でよしなに整形することとします。

```vue:todoリストのDatePicker部分抜粋
<template>
  <datepicker @datePick="dateSet"></datepicker>
</template>

<!-- ～中略～ -->

<script>
  methods:{
    dateSet(pickedDate){
      //datePickerコンポーネントから帰ってきた値を加工してappointedDateに格納する
      //YYYY-MM-DDで受け取るので、MM-DD形式に変換し、09などを9にするためNumberを噛ませる
      this.appointedDate = Number(pickedDate.substr(5,2)) + "/" + Number(pickedDate.substr(8,2))      
    },
</script>
```
前述のdatePickerの操作に応じて`dateSet`が動き、`appointedDate`にToDo期日を格納します。



###1.1-3) ToDo追加ボタン
入力されたToDoを保存するためのボタンです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/352532/3035d2d0-dfb6-64c7-eb83-5be4cef21f83.png)

```vue:ToDo追加ボタン抜粋
  <v-btn 
   small
   color="light-blue accent-4"
   dark
   @click="addTodo()">
   ＋ 追加
  </v-btn>
```
このボタンを押したときに、後述する`addTodo`メソッドでToDoの保存を実行します。



###1.1-4) ToDoをFirestoreに書き込む（1/2:準備）
ここから本格的な機能を実装していきます。
まずはFirestoreを使うための準備です。

Firebaseのアカウント登録・プロジェクト作成をして云々といろいろやることがあるのですが、
はっきり言ってこのあたりの準備手順は他に**素晴らしい解説記事**がたくさんありますので、以下などをご参照いただければと思います。

* [firestore, vue.jsでリアルタイム同期のチャットを実装してみる [チュートリアル形式]](https://qiita.com/ryo2132/items/2881d8223eb2ad3050a1)
* [[Firebase] Firestoreで読み書きする (Web編)](https://blog.katsubemakito.net/firebase/firestore_readwrite_1)

公式ガイドや上記の記事などを参考に、`firebase.js`を作成し`main.js`を編集します。

```javascript:firabase.js
import Vue from 'vue'
import { firestorePlugin } from 'vuefire'
import firebase from 'firebase/app';
import 'firebase/firestore';

Vue.use(firestorePlugin)

const firebaseApp = firebase.initializeApp({
//"xxxxxxxxxxx"の部分は、Firebaseコンソールの
//「設定」→「マイアプリ」から確認できる値に置換する
  apiKey: "xxxxxxxxxxx",
  authDomain: "xxxxxxxxxxx",
  databaseURL: "xxxxxxxxxxx",
  projectId: "xxxxxxxxxxx",
  storageBucket: "xxxxxxxxxxx",
  messagingSenderId: "xxxxxxxxxx",
  appId: "xxxxxxxxxxx",
  measurementId: "xxxxxxxxxxx"
});

export const db = firebaseApp.firestore();
```
```javascript:main.js抜粋
import './plugins/firebase';
```

最後に、firebaseのモジュールを本体のtodo.vueでインポートして準備完了です。

```javascript:todo.vue抜粋
import {db} from '@/plugins/firebase'

//～中略～

created: function() {
//todoコレクションへの参照  
this.todoListRef = db.collection("ToDoList")  
```


###1.1-5) ToDoをFirestoreに書き込む（2/2:ToDo名称+期限の保存）
テキストフォームおよびDatePickerで入力した値をFirestoreに書き込みます。
ここで、書き込む際のメソッドは`add`ではなく**`set`を使います**。

＜参考＞[Cloud Firestore にデータを追加する](https://firebase.google.com/docs/firestore/manage-data/add-data?hl=ja)


`add`はドキュメントIDが無作為に採番されます。つまり降順に採番されていくわけではないので、ID順に表示すると新しいドキュメントが古いドキュメントより前に来たりします。

データ取得の際にソートしてもよいのですが、その場合も結局ソート用の項目を作らないといけないので
`set`を利用しドキュメントIDにミリ秒単位のタイムスタンプを設定しています。
家族しか使わないので、流石にミリ秒単位で重複することは考慮しなくてもよいでしょう。


```javascript:addTodoメソッド
addTodo(){
  //入力がなければ抜ける
  if(this.newTodoName === ""){ console.log("todo入力なし");return }

  //処理日取得（todo登録日用の日付はYY/MM形式に、タイムスタンプはミリ秒形式の文字列に変換）
  const today     = new Date()
  const entryDate = (today.getMonth()+1) + "/" + today.getDate()
  const timestamp = today.toISOString()

  //todolistにsetする
  this.todoListRef.doc(timestamp).set({
  title:this.newTodoName,// 入力されたnewTodoName
  entry_date:entryDate,//todo登録日
  appointed_date:this.appointedDate,//DatePickerで設定したToDo期日
  complete_sts:false // 初期値はfalse（未完了）
  })
```

このメソッドが動くとこんな感じにFirestoreに登録されます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/352532/e39241ae-c13b-9172-0ba1-01d4adc05f3e.png" width=60%>

##1.2 ToDoの取得・表示

Firebase上に保存されているToDo一覧を取得して表示します。
併せて`complete_sts`更新用のチェックボックスと、ToDo削除用の削除ボタンも作ります。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/352532/f888005c-14cd-8f9b-963d-fdcd34691170.png" width=50%>


まずはFirestoreから表示するためのデータを取得します。

```javascript:データ取得処理抜粋
created: function() {

//～中略～

  //todoコレクションへの参照  
  this.todoListRef = db.collection("ToDoList")  
  //onSnapshotメソッドでリスナーを登録
  this.todoListRef.onSnapshot(querySnapshot => { 

    //Firestoreのデータが更新されるたびに以下の処理が実行される
    const obj = {} //object型でデータを取得するため定義しておく
    querySnapshot.forEach(doc => { 
      obj[doc.id] = doc.data()   
      })
    this.todos = obj
    })
```
`onSnapshot`で`querySnapshot`（コレクション配下にあるドキュメントのデータ）を取得し、
`forEach`でワーク用変数`obj`に放り込んでいき、最後に`this.todos`にまとめて格納しています。

このとき、`doc.id`をkeyとして、`doc.data`をkeyに対するデータとして格納しています。
さらに`doc.data`自体もオブジェクト型なので、最終的に`todos`は以下のような入れ子構造になります。

```json-doc:todosの中身
{
  "2020-09-02T15:58:03.939Z": {
    "title": "牛乳買う",
    "appointed_date": "9/2",
    "entry_date": "9/3",
    "complete_sts": true
  },
  "2020-09-06T02:34:55.908Z": {
    "title": "洗剤買う",
    "appointed_date": "9/6",
    "entry_date": "9/6"
    "complete_sts": false,
  }
}
```

※`onSnapshot`は一回きりのデータ取得ではなく**[ドキュメントのリッスン](https://firebase.google.com/docs/firestore/query-data/listen?hl=ja)**を行うので、**Firestoreのデータが更新されるたび**に自動でこの格納処理が動きます。
　このおかげで、ToDoリスト取得処理を何度も実行せずとも、新規作成・更新・削除のたびに常に最新の状態に保たれます。

次に、この`todos`をリスト形式で表示します。

```vue:ToDoリスト表示部分抜粋
<ul>
  <li
   v-for="(todo,key) in todos" :key="key">
    <input
     type="checkbox"
     v-model="todo.complete_sts"
     @click="updateTodo(todo,key)"/>
    <font
     :class="{done: todo.complete_sts}">  
     {{todo.title}}
    </font>
    <font 
      class="appointedDate" >
      {{todo.appointed_date}}
      まで
    </font>
    <font 
      class="entryDate" >
      {{todo.entry_date}}
    </font>
    <v-btn
     x-small
     color="grey darken"
     dark
     margin-left="100px"
     @click="deleteTodo(todo,key)"
     v-show="todo.complete_sts"
     class="delbtn">
     削除
    </v-btn>
  </li>   
</ul>
```
```css:style抜粋
    .entryDate {
      color:gray;
      font-size:70%;
    }
    .appointedDate{
      color:#C62828;
      font-size:85%;
      margin-left: 15px;
    }
    .delbtn {
      margin-left: 15px;
    }
    .done {
      color:gray;
      text-decoration: line-through;
    }
```

`<li>`要素を`v-for`で繰り返し、以下の通り要素を１行に並べて表示しています。

* [チェックボックス] [ToDoタイトル] [ToDo期限] [ToDo登録日] [削除ボタン]

チェックボックス、ToDoタイトル、削除ボタンは`complete_sts`にバインドしており、
`complete_sts`が`true`（完了）か`false`（未完了）かによって表示を切り分けます。

`complete_sts`が`true`（完了）のとき、チェックボックスはチェック状態になり、
ToDoタイトルはdoneクラスが適用されてグレーアウト・打ち消し線が入り、
削除ボタンは表示になります。

上の例だとFirestore側は以下のようになっており、「牛乳買う」が完了、「洗剤買う」が未完了という状態です。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/352532/e0c55daf-8b53-ab61-70d2-9c9a56e1622f.png" width=50%>
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/352532/9ae90bce-6893-a007-4fca-cde22579eafb.png" width=50%>

また、チェックボックスと削除ボタンはそれぞれ`@click`でメソッドを動かすようにしています。
これは後述する「ToDo完了時の状態更新（CRUDの"U"）」「完了したToDoの削除（CRUDの"D"）」で作成するメソッドです。


##1.3 ToDo完了時の状態更新
先ほど登場した`updateTodo`メソッドでは、対象ToDoの`complete_sts`を更新します。

```javascript:updateTodo抜粋

updateTodo(todo,key){
  //checkbox押下時にcomplete_stsを反転させて更新する（完了⇔未完了）
  todo.complete_sts = !todo.complete_sts 
  this.todoListRef.doc(key).update({
    complete_sts: todo.complete_sts 
  })
},    
```

「2.2 ToDoの取得・表示」において`key`は各ドキュメントのIDを設定しているため、
`doc(key).update`とすることで対象のドキュメントを更新することができます。


##1.4 完了したToDoの削除（CRUDの"D"）
削除ボタン押下時に動く`deleteTodo`メソッドです。

```javascript:deleteTodo抜粋
deleteTodo(todo,key){
  //削除ボタン押下時にfirestoreの該当docを削除する
  this.todoListRef.doc(key).delete()
},
```
`updateTodo`と同様に`key`はドキュメントIDなので、
`doc(key).delete`とすることで対象のドキュメントを削除することができます。


以上で**通常のToDoリストとしての要件**であるCRUD機能は揃ったので、
ようやく、今回の本丸である**家族用ToDoリストとしての特別な要件**を作っていきます。

#2.認証機能の作成

このまま公開すると、家族以外の第三者が自由に閲覧・更新できてしまいます。
これをプライベートなサービスとするため、Firebaseで認証されたユーザのみが利用できるようにしていきます。

##2.1 家族のみが閲覧・利用できる
###2.1-1) Firabase Authenticationの設定
FirebaseのAuthentication設定画面で、家族のユーザ情報を登録しておきます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/352532/094a79d0-d8c3-4852-8fa1-3e15e9d888f0.png" width=60%>

「ユーザーを追加」ボタンを押下するとメールアドレスとパスワードを入力できるようになるので、適宜入力して追加します。
(Sign-in methodタブから「メール / パスワード」を有効にしておく必要があります)
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/352532/c5261fcc-6557-dfc2-1483-116e8c8a934c.png" width=50%>


最後に、認証ユーザでなければ読み書きの両方を拒否するようFirestoreのルールを設定しておきます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/352532/88626269-5157-2d10-f944-744a9f32eeee.png" width=70%>

###2.1-2) ログイン機能の作成
先ほど設定したメールアドレスとパスワードを入力し、認証を行う画面を作成します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/352532/164033b1-382c-67af-db65-97358ddf4820.png" width=50%>

```vue:ログインフォーム抜粋
<v-text-field
 v-model="email"
 label="E-mail"
 required>
</v-text-field>

<v-text-field
 v-model="password"
 label="Password"
 type="password"
 required>
</v-text-field>

<v-btn
 color="light-blue darken-3"
 dark
 large
 @click="signIn">
  sign-in
</v-btn>
```
```javascript:ログイン処理抜粋
signIn: function () {
  console.log("signin")
  firebase.auth().signInWithEmailAndPassword(this.email, this.password).catch(function(error) {
  //エラー時はポップアップを出す
        alert(error.message)
  });
//認証状態（userがnullでない）のときにtodoに遷移する
firebase.auth().onAuthStateChanged(function(user) {
  if (user) {
    location.href="#/todo";
  }
})
}
```

入力されたメールアドレスとパスワードはそれぞれ`email`と`password`にバインドし、
`signInWithEmailAndPassword`の引数として渡すことでログイン処理を行います。
認証に成功した場合、ToDoリストの画面に切り替えています。


また、認証済みの状態でログイン画面を表示したとき（一度閉じて開き直したときなど）は
自動でToDoリストを表示したいので、`created`フックでリダイレクトさせます。

```javascript:ログイン画面リダイレクト処理抜粋
created: function(){
  //初期表示時にログイン済みであればtodoにリダイレクト
    firebase.auth().onAuthStateChanged(function(user) {
    if (user) {
      location.href="#/todo";
   }})
  },
```


これの逆をToDoリストにも組み込みます。
非認証状態でToDoリスト画面を表示しようとした際に、ログイン画面にリダイレクトさせます。

```javascript:todoリスト画面リダイレクト処理抜粋
created: function() {
  //認証状態でない場合、サインイン画面にリダイレクト
  firebase.auth().onAuthStateChanged(function(user) {
  if (!user) {
    location.href="#/signin";
  }})
```
<br>
###2.1-3)  ログアウト機能の作成
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/352532/75867e39-25e6-371d-9ed2-696514c01101.png" width=50%>

あまり使うことはないですが、ログアウトもできるようにしておきます。

```vue:ログアウトボタン抜粋
<v-btn
 @click="signOut"
 color="grey darken-1"
 dark
 dense>
 sign-out
</v-btn>
```
```javascript:ログアウト処理抜粋
signOut(){
    firebase.auth().signOut()
    this.$router.push('signin')
  } 
```
ログアウトは非常に簡単で、`signOut()`を実行するだけです。
ログアウト後、signin画面に戻るようにしています。



##2.2 ToDoを誰が新規作成したかがわかる
これまでの機能だけだと、家族の「誰が」登録したToDoなのかがわかりません。
よって、以下のようにToDoの登録者が誰であるかわかるようにします。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/352532/7a442ec2-1c38-3644-c2c5-13e540bc52ec.png" width=50%>
本来、ユーザのプロフィール情報から名前（`displayName`）を取得して`addTodo`で一緒に登録すればよいのでとても簡単なのですが
今回の場合、以下2つの問題があります。

* メールアドレスをFirebaseコンソールから登録しているので、ユーザプロフィールに`displayName`が存在しない
* `displayName`の代わりにユーザの`email`を名前のように使ってもよいが、**イケてない**

1点目は、たとえばユーザ認証をGoogleアカウントなどで実施していた場合、Googleのユーザ名が`displayName`としてそのまま使えるのですが、
今回はメールアドレスとパスワードのみをもったユーザであるため、デフォルトでは`displayName`には何も設定されていません。

コンソールなどからどうにか編集できないか調べたのですが見当たらず、以下の力技で解決することにしました。
もし良いやり方をご存じの方がいらっしゃれば教えて下さい……。

###2.2-1) `displayName`登録フォームを作る
手作業で登録する方法はわかりませんでしたが、`displayName`を更新するメソッドがあることはわかったため、`displayName`登録フォームを作成しました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/352532/03a8aada-a779-4081-f32b-db17811ad254.png" width=50%>

```vue:displayName登録フォーム抜粋
<v-text-field
 color="grey darken-1"
 label="新しいユーザ名を入力してください"
 v-model="userName"
 >
</v-text-field> 

{{msg}}<br> 

<v-btn
 @click="updateName"
 color="blue darken-3"
 dark>
  submit
</v-btn>
```
```javascript:updateName抜粋
updateName: function () {
  var user = firebase.auth().currentUser;

  //thisの参照が切れるのでselfに退避
  const self = this

  //入力されたuserNameで、ログイン中ユーザのdisplayNameを更新
  user.updateProfile({ displayName:self.userName }).then(function() {
  self.msg = "登録成功"
  }).catch(function(error) {
  console.log(error)
  self.msg = "登録失敗"
  });
  this.userName=""
}
```

ログインした状態で任意の名前を入力してsubmitボタンを押すと、そのログイン中ユーザの`displayName`が登録（更新）されます。
これを全ユーザ分行って`displayName`を登録します。

**初回の登録作業が終わればこのフォームは不要なので、あとで消してしまってOKです。**
<br>
###2.2-2) ToDo登録者名を書き込み・読み取り・表示する
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/352532/e0879c8f-456b-395d-8259-a7796a6930ff.png" width=50%>
最後に、`addTodo`メソッドにおいてログインユーザの`displayName`を`entry_user`という項目名でFirestoreに書き込み、
またtemplate部でもFirestoreから取得した`entry_user`を表示させるようにします。

```javascript:addTodoメソッド(2)
addTodo(){
  //入力がなければ抜ける
  if(this.newTodoName === ""){ console.log("todo入力なし");return }

  //ログイン中のユーザ情報を取得
  const user = firebase.auth().currentUser;

  //ログイン状態であれば処理を行う
  if (user != null) {

    //currentUserのdisplayNameを設定
    const userName  = user.displayName; 

    //処理日取得（todo登録日用の日付はYY/MM形式に、タイムスタンプはミリ秒形式の文字列に変換）
    const today     = new Date()
    const entryDate = (today.getMonth()+1) + "/" + today.getDate()
    const timestamp = today.toISOString()
        
    //todolistにsetする
    this.todoListRef.doc(timestamp).set({
    title:this.newTodoName,// 入力されたnewTodoName
    entry_user:userName,//ログイン中ユーザのdisplayName
    entry_date:entryDate,//todo登録日
    appointed_date:this.appointedDate,//DatePickerで設定したToDo期日
    complete_sts:false // 初期値はfalse（未完了）
    })
  }
```
```vue:ToDoリスト表示部分抜粋
<ul>
  <li
   v-for="(todo,key) in todos" :key="key">

  <!--中略-->
    <font 
      class="entryUserName" >
      {{todo.entry_user}}
    </font> 
  <!--中略-->

  </li>   
</ul>
```
```css:style抜粋(2)
    .entryUserName {
      color:gray;
      font-size:70%;
      margin-left: 10px;
```




##3.参考文献

* [VueJS入門その30「VueJSとFirestoreでTODOリストを作る(1)」(YouTube)](https://youtu.be/_n67scuq4QQ)
* [Vue.js+Firebase Authenticationで認証画面を爆速実装！](https://www.isoroot.jp/blog/2451/)
* [firestore, vue.jsでリアルタイム同期のチャットを実装してみる [チュートリアル形式]](https://qiita.com/ryo2132/items/2881d8223eb2ad3050a1)
* [[Firebase] Firestoreで読み書きする (Web編)](https://blog.katsubemakito.net/firebase/firestore_readwrite_1)