Rails APIモード + devise_token_auth + Vue.js 3 で認証機能付きのSPAを作る(Vue.js編 その2)

# はじめに
本記事はAPIをRailsのAPIモードで開発し、フロント側をVue.js 3で開発して、認証基盤にdevise_token_authを用いてトークンベースの認証機能付きのSPAを作るチュートリアルのVue.js編の記事(その2)になります。

Rails側のチュートリアルを終わらせてからこちらのチュートリアルに取り組まれることを推奨します。

前回: [Vue.js編その1](https://qiita.com/kk-icare/items/401114a1284f8e9ca135)

次回: [Navigation Guard編](https://qiita.com/kk-icare/items/6389ab6ff83f859d0e84)

# 環境
Vue.js 3.0.5
Vue CLI 4.5.9
npm 6.14.8
node 14.15.0
TypeScript 3.9.7
TailwindCSS

# TailwindCSSの導入

スタイルが全く当たらないのは味気ないので、今流行りのTailwindCSSを導入したいと思います。

下記公式ドキュメントに沿って導入を進めます。

https://tailwindcss.com/docs/guides/vue-3-vite

ただ2021年３月27日の時点だと、Doc通り導入を進めても、npn run serveした時に以下のエラーが出て
正しく起動できないようです。

```
Syntax Error: Error: PostCSS plugin tailwindcss requires PostCSS 8.
Migration guide for end-users:
https://github.com/postcss/postcss/wiki/PostCSS-8-for-end-users
```

なので、Document通りに進めて、npm run serveした際に以下のエラーが出る場合は、

```
 ERROR  Failed to compile with 1 error                                                                                         21:34:40

 error  in ./src/index.css

Syntax Error: Error: PostCSS plugin tailwindcss requires PostCSS 8.
Migration guide for end-users:
https://github.com/postcss/postcss/wiki/PostCSS-8-for-end-users
```

以下のnpm installを実行してください。

```terminal
npm install -D tailwindcss@npm:@tailwindcss/postcss7-compat@2.0.3 postcss@7.0.35 autoprefixer@9.8.6
```

[サンプルのプロジェクトに導入した際のコミットはこちら](https://github.com/keisuke-koshikawa/sample-front/commit/c829eb8601a6f9213ad3d040ab296140c4969e90)ですので、導入に詰まったらご参考ください。

# ログイン画面にスタイルを当てる

早速tailwind CSSの動作確認も兼ねて、殺風景なログイン画面をいい感じにしてみましょう。

src/views/Login.vueのtemplateタグ内を以下のコードに修正してください。

```html
<template>
  <div class="mt-16 px-16 mx-auto xl:max-w-3xl">
    <h2 class="text-center text-4xl text-indigo-900 font-display font-semibold lg:text-left xl:text-5xl
      xl:text-bold">
      Log in
    </h2>
    <div class="mt-12">
      <div class="mt-8">
        <div class="flex justify-between items-center">
          <div class="text-sm font-bold text-gray-700 tracking-wide">
            Email
          </div>
        </div>
        <input class="w-full text-lg py-2 border-b border-gray-300 focus:outline-none focus:border-indigo-500" v-model="email" type="email" placeholder="Enter your Email">
      </div>
      <div class="mt-8">
        <div class="flex justify-between items-center">
          <div class="text-sm font-bold text-gray-700 tracking-wide">
            Password
          </div>
        </div>
        <input class="w-full text-lg py-2 border-b border-gray-300 focus:outline-none focus:border-indigo-500" v-model="password" type="password" placeholder="Enter your password">
      </div>
      <div class="mt-10">
        <button class="bg-indigo-500 text-gray-100 p-4 w-full rounded-full tracking-wide
                       font-semibold font-display focus:outline-none focus:shadow-outline
                       hover:bg-indigo-600 shadow-lg"
                @click="handleLogin"
        >
          Log In
        </button>
      </div>
    </div>
  </div>
</template>
```

この状態で npm run serve コマンドを実行して、 http://localhost:8080/login にアクセスして、以下の添付画像のような表示になっていれば、tailwind CSSの導入には成功しています。

![スクリーンショット 2021-04-27 22.16.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/5abbce7c-4cb8-fe23-c8e5-5605e07b4039.png)


# 新規投稿画面の作成

まずは新規投稿画面から作成します。

## src/views/NewPost.vueの作成

以下コマンドを実行して下さい。

```
$ touch src/views/NewPost.vue
```

出来上がったファイルを以下のように編集します。

```vue
<template>
  <div class="flex items-center h-screen w-full bg-teal-lighter">
    <div class="w-full bg-white rounded shadow-lg p-8 m-4">
      <h1 class="block w-full text-center text-grey-darkest mb-6">New Post</h1>
      <div class="flex flex-col mb-4">
        <label class="mb-2 font-bold text-lg text-grey-darkest" for="title">Title</label>
        <input v-model='title' class="border py-2 px-3 text-grey-darkest" type="text" name="first_name" id="first_name">
      </div>
      <div class="flex flex-col mb-4">
        <label class="mb-2  font-bold text-lg text-grey-darkest" for="body">Body</label>
        <textarea v-model='body' class="border py-2 px-3 text-grey-darkest" name="body" id="body"></textarea>
      </div>
      <button @click='handleCreatePost()' class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 uppercase text-lg mx-auto rounded" type="submit">Create Post</button>
    </div>
  </div>
</template>

<script lang="ts">
import { createPost } from '@/api/post'
import router from '@/router'
import { defineComponent, reactive, toRefs } from 'vue'

export default defineComponent({
  name: 'NewPost',
  setup () {
    const postData = reactive({
      title: '',
      body: ''
    })

    const handleCreatePost = async () => {
      await createPost(postData)
        .then((res) => {
          console.log(res)
        })
    }

    return {
      ...toRefs(postData),
      handleCreatePost
    }
  }
})
</script>

<style scoped></style>
```

解説を入れていきます。

```vue
<template>
  <div class="flex items-center h-screen w-full bg-teal-lighter">
    <div class="w-full bg-white rounded shadow-lg p-8 m-4">
      <h1 class="block w-full text-center text-grey-darkest mb-6">New Post</h1>
      <div class="flex flex-col mb-4">
        <label class="mb-2 font-bold text-lg text-grey-darkest" for="title">Title</label>
        <input v-model='title' class="border py-2 px-3 text-grey-darkest" type="text" name="first_name" id="first_name">
      </div>
      <div class="flex flex-col mb-4">
        <label class="mb-2  font-bold text-lg text-grey-darkest" for="body">Body</label>
        <textarea v-model='body' class="border py-2 px-3 text-grey-darkest" name="body" id="body"></textarea>
      </div>
      <button @click='handleCreatePost()' class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 uppercase text-lg mx-auto rounded" type="submit">Create Post</button>
    </div>
  </div>
</template>
```

template部分はTailwindCSSを当てて、最低限の見た目を整えています。
inputタグにはv-modelディレクティブにtitleとbodyを指定し、templateとscript側で双方向データバインディングを行っています。
buttonタグには@clickにhandleCreatePost関数を指定し、ボタンがクリックされたら関数が呼ばれるようにしています。

```vue
<script lang="ts">
import { createPost } from '@/api/post'
import router from '@/router'
import { defineComponent, reactive, toRefs } from 'vue'

export default defineComponent({
  name: 'NewPost',
  setup () {
    const postData = reactive({
      title: '',
      body: ''
    })

    const handleCreatePost = async () => {
      await createPost(postData)
        .then((data) => {
          console.log(data)
        })
    }

    return {
      ...toRefs(postData),
      handleCreatePost
    }
  }
})
</script>
```

createPost関数は後ほど定義しますが、引数としてフォームの値を受け取って、POSTリクエストを送る関数になっています。
routerは新規作成の成功した後にページ遷移をしたいのでimportしています。
あとはCompositionAPIを使うために各種関数をimportしています。

titleとbodyはreactive関数でリアクティブな値にし、toRefs関数で分割して定義するようにしています。

handleCreatePost関数はcreatePost関数をコールする関数として定義し、template内で使えるようにreturn節に記載しています。

src/router/index.tsも編集します。

```javascript
import Login from '@/views/Login.vue'
import NewPost from '@/views/NewPost.vue' // 追加

  {
    path: '/login',
    name: 'Login',
    component: Login
  },
  // 以下を追加
  {
    path: '/posts/new',
    name: 'NewPost',
    component: NewPost
  },
```

## src/api/post.tsの作成

POSTリクエストをAPIに投げる関数を定義していきます。

以下コマンドを実行してください。

```terminal
$ touch src/api/post.ts
```

作成したファイルを以下のように編集します。

```typescript
import Client from '@/api/client'
import { Post, PostForRequest } from '@/types/post'
import {
  getAuthDataFromStorage
} from '@/utils/auth-data'
import { AxiosResponse } from 'axios'

export const createPost = async (formData: PostForRequest) => {
  return await Client.post(
    '/posts', formData,
    {
      headers: getAuthDataFromStorage()
    }
  )
    .then((res: AxiosResponse<Post>) => {
      return res.data
    })
}
```

Client.postでAPIに対してformData付きでリクエストを送信できるようにしています。
通信に成功した場合はレスポンスからdataを抜き出して返却し、失敗した場合はエラーを返すようにしています。

src/types/post.tsに型定義を追加します。

```terminal
$ touch src/types/post.ts
```

```typescript
export type Post = {
  title: string;
  body: string;
  userName: string;
  createdAt: string;
}

export type PostForRequest = Pick<Post, 'title' | 'body'>
```

この状態でAPI側のサーバーを起動して動作確認を行ってみます。

![スクリーンショット 2021-03-28 19.44.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/92e8d217-ead4-16b8-109d-0a2abbbc761b.png)

localhost:8080/posts/newにアクセスし、フォームに値を入力した状態でCREATE POST ボタンを押すと、

通信に成功すればコンソールにAPIから返却されたデータが表示されます。

![スクリーンショット 2021-03-28 19.44.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/7c7ee15c-1156-5dda-7b87-fd49a14fe6c6.png)

このデータを次の項で作成する投稿一覧画面に表示したいと思います。

# 投稿一覧画面の作成

## Post.vueの作成

以下のコマンドを実行してください。

```terminal
$ touch src/views/Post.vue
```

作成されたファイルを以下のように編集してください。

```vue
<template>
  <div class="grid grid-cols-3 gap-1">
    <AppPost :post="post" v-for="(post, index) in posts" :key="index" />
  </div>
</template>

<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue'
import AppPost from '@/components/AppPost.vue'
import { getPosts } from '@/api/post'
import { Post } from '@/types/post'

export default defineComponent({
  name: 'Post',
  components: {
    AppPost
  },

  setup () {
    const posts = ref([] as Post[])
    const onGetPosts = async () => {
      await getPosts()
        .then((res) => {
          posts.value = res
        })
        .catch((err: Error) => {
          console.info(err.message)
          alert('原因不明のエラーが発生しました。リロードすることで解決することがあります。')
        })
    }

    onMounted(() => {
      onGetPosts()
    })

    return {
      posts
    }
  }
})
</script>

```

AppPostは後に定義するコンポーネントです。

Post.vueの仕事は、

1. APIと通信してデータを取ってくる
2. 取ってきたデータを子コンポーネントに渡す

の2つの仕事を担います。

実際にAPIを叩く処理は、post.tsに定義し、

```javascript
import { getPosts } from '@/api/post'
```

という形でimportして用います。

Postの表示も子コンポーネントに委任しています。

コンポーネントがマウントされたタイミングでAPIと通信したいので、onMountedでAPIを叩く関数(onGetPosts)をコールするようにしています。

onGetPostsは、post.tsに定義されたgetPosts関数をコールして、レスポンスのデータをRef型で定義されたposts変数に格納するようにしています。

## post.tsの拡張

次にgetPosts関数をsrc/api/post.tsに定義します。

```typescript
import Client from '@/api/client'
import { Post, PostForRequest } from '@/types/post'
import {
  getAuthDataFromStorage
} from '@/utils/auth-data'
import { AxiosResponse } from 'axios'

// 以下を追加

export const getPosts = async () => {
  return await Client.get('/posts', { headers: getAuthDataFromStorage() })
    .then((res: AxiosResponse<Post[]>) => {
      return res.data
    })
    .catch((err) => {
      return err.response
    })
}

// ここまで

export const createPost = async (formData: PostForRequest) => {
  return await Client.post(
    '/posts', formData,
    {
      headers: getAuthDataFromStorage()
    }
  )
    .then((res: AxiosResponse<Post>) => {
      return res.data
    })
}
```

Client.getでAPIに対してGETリクエストを送るようにしています。
通信に成功した場合、レスポンスのデータを取り出して返却するようにしています。

## src/components/AppPost.vueの作成

次に、AppPost.vueを作成します。

以下のコマンドを実行してください。

```
$ touch src/components/AppPost.vue
```

作成されたファイルを以下のように編集します。

```vue
<template>
  <div class="rounded overflow-hidden shadow-lg mt-8 pt-8 mr-8">
    <div class="font-bold text-xl mb-2">{{post.title}}</div>
    <p class="text-grey-darker text-base">
      {{ post.body }}
    </p>
  </div>
</template>

<script lang="ts">
import { Post } from '@/types/post'
import { defineComponent, PropType } from 'vue'

export default defineComponent({
  name: 'AppPost',
  props: {
    post: {
      type: Object as PropType<Post>,
      required: true
    }
  }
})
</script>

<style scoped>

</style>
```

propsで渡ってきたpostをdefineComponent内で定義します。
PropTypeを使うと、propsに型を付与することができます。

参考: https://kossy-web-engineer.hatenablog.com/entry/2021/01/17/000946

あとは渡ってきたpropsをtemplate内で表示するだけです。

## src/router/index.tsの編集

一覧画面のルーティングを追加します。

```javascript
import NewPost from '@/views/NewPost.vue'
import Post from '@/views/Post.vue' // 追加

省略
  {
    path: '/login',
    name: 'Login',
    component: Login
  },
  // ここから追加
  {
    path: '/posts',
    name: 'Post',
    component: Post
  },
  // ここまで
```

ここまでできたら、一通り動作の確認をしてみましょう。

localhost:8080/postsにアクセスしてみます。

![スクリーンショット 2021-03-28 20.13.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/b04d89ce-c2ac-fc42-c3bb-4b7b22a596e4.png)

先ほど投稿したPostが表示されていれば成功です。

あとは、NewPost.vueでconsole.logとしていた部分を修正します。

```javascript
    const handleCreatePost = async () => {
      await createPost(postData)
        .then(() => {
          router.push('/posts')
        })
    }
```

## ナビゲーションバーの作成

投稿機能と直接関係がないですが、ナビゲーションバーを作成します。

以下のコマンドを実行してください。

```terminal
$ touch src/components/NavBar.vue
```

作成されたファイルを以下のように編集します。

```vue
<template>
  <nav class="bg-gray-800">
    <div class="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8">
      <div class="relative flex items-center justify-between h-16">
        <div class="flex-1 flex items-center justify-center sm:items-stretch sm:justify-start">
          <div class="flex-shrink-0 flex items-center">
          </div>
          <div class="hidden sm:block sm:ml-6">
            <div class="flex space-x-4">
              <button @click='movePosts()' class="bg-gray-900 text-white px-3 py-2 rounded-md text-sm font-medium">Top</button>
            </div>
          </div>
        </div>
        <div class="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0">
          <div class="ml-3 relative">
            <div>
              <button class="bg-gray-800 flex text-sm rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white" id="user-menu" aria-haspopup="true">
                <img class="h-8 w-8 rounded-full" src="../assets/logo.png">
              </button>
            </div>
          </div>
        </div>
        <div class="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0">
          <div class="ml-3 relative">
            <div>
              <button @click='moveNewPost()' class="bg-gray-900 text-white px-3 py-2 rounded-md text-sm font-medium">create Post</button>
            </div>
          </div>
        </div>
      </div>
    </div>
  </nav>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import router from '@/router'

export default defineComponent({
  name: 'NavBar',

  setup () {
    const movePosts = () => {
      router.push('/posts')
    }

    const moveNewPost = () => {
      router.push('/posts/new')
    }

    return {
      movePosts,
      moveNewPost
    }
  }
})
</script>
```

投稿画面に遷移するボタンと一覧画面に遷移するボタンを定義しています。

このコンポーネントをApp.vueで呼ぶようにします。

```vue
<template>
    <NavBar />
    <div class='container mx-auto'>
      <router-view/>
    </div>
</template>

<script lang='ts'>
import { defineComponent } from 'vue'
import NavBar from '@/components/NavBar.vue'

export default defineComponent({
  components: {
    NavBar
  }
})
</script>

<style lang="scss">

</style>
```

この状態で動作を確認しましょう。

![スクリーンショット 2021-04-07 20.56.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/6d4c07d8-c785-634f-e67f-4513fbdd28c3.png)

問題なくナビゲーションバーが表示されています。

# ログイン成功後の画面遷移先の変更

最後にログインに成功した後に http://localhost:8080/posts に遷移するように修正します。

```vuejs
<script lang="ts">
import { defineComponent, reactive, toRefs } from 'vue'
import { login } from '@/api/auth'
import router from '@/router' // 追加

export default defineComponent({
  name: 'Home',
  setup () {
    const formData = reactive({
      email: '',
      password: ''
    })

    return {
      ...toRefs(formData),
      handleLogin: async () => {
        await login(formData.email, formData.password)
          .then((res) => {
            if (res?.status === 200) {
              router.push('/posts') // 変更
            } else {
              alert('メールアドレスかパスワードが間違っています。')
            }
          })
          .catch(() => {
            alert('ログインに失敗しました。')
          })
      }
    }
  }
})
</script>
```

# まとめ

これで、ログイン & 投稿機能が出来上がったかと思います。

次回は更に機能の拡張を行っていきたいと思います。