78
90

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【全部入り】Vue.js + RailsAPIでブログアプリを作る(vuex,vue-routerも)

Last updated at Posted at 2020-04-17

近々業務で本格的にvueを使うのでアウトプットとして、一通りの機能を持ったアプリを作ってみました。

vue-cliとRailsで作成したAPIを使ってブログアプリを作っていきます。

未熟なため用語の不適切な使い方など多数散見されると思いますが、あなたの熱い想いはコメント欄で吐き出しまくってください。めっちゃありがたく頂戴いたします。

主な機能

  • 投稿
  • 編集
  • 削除
  • vue-routerによるページ遷移
  • vuexによる状態管理
  • サクセスメッセージ 、validation

前提

  • vueコマンドが使える。
  • vueをかじったことがある(v-modelとかなんとなくわかる)。

詳しい説明は公式ドキュメントなどを参考にしていただけると🙇‍♂️

ちなみに自分はVue.js入門という本で網羅的に勉強しました。
技術書はリーダブルコード以来だったのですが、アホみたいにわかりやすかったです。

環境

Mac
Rails 5.2.4.2
ruby 2.5.5
npm 6.13.7

RailsでAPIを作る

まずはAPIから作っていきましょう。

apiモードでRailsアプリを作成

$ rails new blog-app --api

末尾に--apiとつけることでAPIモードでアプリを作成できます。

コントローラーとモデルを作る

今回必要なコントローラーとモデルを作っていきましょう。

$ rails g controller v1::blogs
$ rails g model Blog title:string body:text

v1::blogsとすることでバージョンで名前空間を作成します。

blogsコントローラーを以下のように設定します。

controllers/v1/blogs_controller

class V1::BlogsController < ApplicationController
  def index
    blogs = Blog.all
    render json: blogs
  end

  def update
    blog = Blog.find(params[:id])
    if blog.update(blog_params)
      render json: blog
    else
      render json: blog.errors
    end
  end

  def create
    blog = Blog.new(blog_params)
    if blog.save
      render json: blog, status: :created
    else
      render json: blog.errors, status: :unprocessable_entity
    end
  end

  def destroy
    blog = Blog.find(params[:id])
    if blog.destroy
      render json: blog
    end
  end

  private
  def blog_params
    params.require(:blog).permit(:title, :body)
  end
end

indexアクションは今回のロジック上は必要ないですが、ブラウザ上でデータを見やすくするために作成しました。

views/blogs/index.html.erb
<% @blogs.each do |blog| %>
  <%= blog.title %>
  <%= blog.body %>
<% end %>

簡単にデータを表示できるように記述しておきます。

config/routes.rb
Rails.application.routes.draw do
  namespace :v1 do
    resources :blogs, only: [:create, :destroy, :index, :update]
  end
end

ここもきっちり名前空間で区切っておきましょう。

クロスドメインによるエラーを解決

基本的な機能はできたのですが、このままだとAPI通信をした際にconsoleで以下のエラーが出てしまいます。

Access to XMLHttpRequest at 'https://... ' from origin 'http://... ' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource

このエラーを解決するために以下の作業をします。

gemfile
gem 'rack-cors'

デフォルトではコメントアウトされているので外してあげてください。

$ bundle install

rack-corsを入れると以下のファイルが現れるので同様に記述してください。

config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins '*' #<=全てのオリジンからのリクエストを許可 

    resource '*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

ここまででAPIの作成は終了です。いよいよvueのプロジェクトに入りましょう。

vue-cliをインストール

まずはvue-cliをインストールして環境を作ります。以下の記事がわかりやすいです。
Vue.js を vue-cli を使ってシンプルにはじめてみる

インストールできたら以下のコマンドでプロジェクトを作成します。

$ vue create blog-app

Manuallyを選択。

? Please pick a preset: 
   default (babel, eslint) 
❯  Manually select features 

vuexとvue-routerを選択。

Vue CLI v3.4.0
? Please pick a preset: Manually select features
? Check the features needed for your project: (Press <space> to select,
 <a> to toggle all, <i> to invert selection)
 ◉ Babel
 ◯ TypeScript
 ◯ Progressive Web App (PWA) Support
 ◉ Router
❯◉ Vuex
 ◯ CSS Pre-processors
 ◉ Linter / Formatter
 ◯ Unit Testing
 ◯ E2E Testing

この後いくつか質問が聞かれますが、全部EnterでOKです。

ターミナルで以下のようになったら成功です。
cdでアプリのディレクトリに移動し、npm run serveコマンドを実行してください。

Successfully created project blog-app.
👉  Get started with the following commands:
 
 $ cd blog-app
 $ npm run serve

うまくいっていれば以下のようになります。
vue-cli-top.png

ここまででvueアプリの雛形ができました。

一覧表示ページを作る

まずは保存したブログが一覧で見ることができるトップページのようなものを作っていきましょう。

今回、デザインはvuetifyというcssフレームワークにまかせますのでダウンロードしておきます。

$ vue add vuetify

? Choose a preset: Default

現状のフォルダを確認しておきましょう。

src/App.vue
<template>
  <div id="app">
    <v-container>
      <router-view/>
    </v-container>
  </div>
</template>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 50px;
}
</style>

デフォルトで入っているHelloWorldは使わないので削除しておきます。

基本的に今回作っていくページは全てこの<router-view/>の中に代入されていきます。

Railsアプリでいう<%= yield %>みたいな感じですね?

次にルーティングを設定していきます。

src/router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
import Blogs from '../views/Blogs.vue' // 追加

Vue.use(VueRouter)

  const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/blogs',    // 追加
    name: 'blogs',
    component: Blogs
  }
]

const router = new VueRouter({
  routes
})

export default router

こうすることでpath: '/blogs'というルートにアクセスすると、views/Blogs.vueファイルが呼ばれるようになります。

このBlogs.vueが一覧表示ページになります。

ただ今回はルートページはvue-routerの簡単な練習もかねてHome.vueに設定してみました。
これはデフォルトでインストールされたフォルダです。
ちょっとだけいじっていきましょう。

src/views/Home.vue
<template>
  <div class="home">
    <h1>Welcome to Blog App !</h1>
    <router-link to="/blogs">start</router-link>
  </div>
</template>

<script>

export default {
  components: {
  }
}
</script>

router-linkto=""でルーティングパスを渡してあげることができます。

startというリンクをクリックすると先ほど設定した/blogsに飛ぶようになっています。

つまりBlogs.vueが表示されるということです。

ではBlogs.vueを作っていきます。

src/views/Blogs.vue
<template>
  <div>
    <h1>Blogs</h1>
    <v-row>
      <v-col cols="4">
      <form>
        <v-text-field
          v-model="blog.title"
          label="Title"
        ></v-text-field>
        <v-textarea
          v-model="blog.body"
          label="Body"
        ></v-textarea>

        <v-btn class="mr-4" @click="onSubmit">Create</v-btn>
      </form>
      </v-col>
      <v-col cols="8">
        <table>
          <tr>
            <th>Title</th>
            <th>Description</th>
          </tr>
          <tr>
            <td>aa</td>
            <td>aa</td>
            <td>show</td>
            <td>Edit</td>
            <td>Destroy</td>
          </tr>
        </table>
      </v-col>
    </v-row>
  </div>
</template>

<script>
export default {
  data() {
    return {
      blog: {}
    }
  },
  methods: {
    onSubmit() {
    }
  }
}
</script> 

まだ何も投稿していないので適当にtableにデータを入れておきます。ここは後ほど自動化していきます。

現状だとボタンをクリックしても関数を設定していないので何もおきません。

ここまでで以下のような挙動になっていればOKです。
ezgif.com-video-to-gif.gif

では実際にAPI通信を挟んでデータを表示、追加してみましょう。

APIと通信してデータを表示する

まずはaxiosという非同期でHTTP通信をしたいときに、Vue.jsでよく使われるHTTPクライアントをインストールします。

$ npm install --save axios

api通信のベースとなるファイルを作ります。
apiフォルダとその下にindex.jsファイルを作成してください。

src/api/index.js
import axios from 'axios'

export default () => {
  return axios.create({
    baseURL: 'http://0.0.0.0:3000/v1'
  })
}

先ほどインストールしたaxiosをインポートして使えるようにしています。
ここで上記のようにaxiosを関数化しておくと全てのファイルで簡単に呼び出すことができます。

さらにbaseURLとしてAPI側のURLを指定しておくと、リクエストの際にいちいち'http://0.0.0.0:3000/v1/blogs'などのように書かなくてよくなります。
'/blogs'だけで上記のURLと同じ通信することができるのです。

vuexで状態を管理できるようにする

src/store/index.jsを以下のように書き換えます。

  1. 先ほど作成したapiファイルをimport
  2. state,actions,mutationsを書き換える
  3. modulesは今回使わないので消す
src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import axios from '@/api/index'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    blogs: []//④
  },
  mutations: {
    FETCH_BLOGS(state, blogs) {//③
      state.blogs = blogs
    }
  },
  actions: { 
    async fetchBlogs({ commit }) {//①
      await axios().get('/blogs')
        .then(res => {
          commit('FETCH_BLOGS', res.data)//②
        })
        .catch(e => console.log(e))
    }
  }
})

vuexは以下の公式の画像でよく説明されます。↓
vuex.png

流れを簡単に説明すると、

  1. ブラウザでクリックなどのイベントが起こる
  2. actionsでAPIとの通信を挟む
  3. mutationsにactionsで通信したデータをコミットする
  4. stateに最終状態として挿入する

みたいな感じです。
上のコードの番号①〜④の順で実行されます。

では、サーバーから取ってきた情報を実際に表示させてみましょう。

以前作ったRailsAPIアプリを起動します(いちいち起動するのがめんどくさい人はRailsアプリだけ先にデプロイしてしまってもOKです)。

 $ rails s -b 0.0.0.0

このときポート番号が両方とも同じだと衝突してエラーになってしまうので注意です。
幸い、vue-cliはデフォルトで8080、railsは3000を使っているので今回は大丈夫です。

まだサーバーにデータが何も入っていない状態なのでconsoleから作成してみましょう。

  1. ターミナルでrails cコマンド
  2. Blog.create(title: "test1", body: "This is a test1")で適当に何かデータを追加
$ rails c
Running via Spring preloader in process 2127
Loading development environment (Rails 5.2.4.2)
irb(main):001:0> Blog.create(title: "test1", body: "This is a test1")
   (0.1ms)  begin transaction
  Blog Create (8.0ms)  INSERT INTO "blogs" ("title", "body", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["title", "test1"], ["body", "This is a test1"], ["created_at", "2020-04-17 08:13:47.969926"], ["updated_at", "2020-04-17 08:13:47.969926"]]
   (3.4ms)  commit transaction
=> #<Blog id: 1, title: "test1", body: "This is a test1", created_at: "2020-04-17 08:13:47", updated_at: "2020-04-17 08:13:47">

これで現在サーバーにはデータが一つだけ入っている状態になります。
API通信をしてBlogs.vueページ上に表示させましょう。

App.vueに以下を追加します。

src/App.vue
<script>
export default {
  created() {
    this.$store.dispatch('fetchBlogs')
  },
}
</script>

ここでライフサイクルの中のcreatedというフックを実行します。

インスタンスが作成された後に同期的に呼ばれます。この段階では、インスタンスは、データ監視、算出プロパティ、メソッド、watch/event コールバックらのオプションのセットアップ処理が完了したことを意味します。

これで何かしらのアクションが起こるたびに先ほど定義したfetchBlogsアクションを実行します。

これでサーバーからデータを取得できるようになりました。
Blogs.vueに取得したデータを表示できるようにしていきましょう。

src/views/Blogs.vue
<template>
  <div>
    <h1>Blogs</h1>
    <v-row>
      <v-col cols="4">
      <form>
        <v-text-field
          v-model="blog.title"
          label="Title"
        ></v-text-field>
        <v-textarea
          v-model="blog.body"
          label="Body"
        ></v-textarea>

        <v-btn class="mr-4" @click="onSubmit">Create</v-btn>
      </form>
      </v-col>
      <v-col cols="8">
        <table>
          <tr>
            <th>Title</th>
            <th>Description</th>
          </tr>
          <tr v-for="blog in blogs" :key="blog.id">  <!-- 変更 ③-->
            <td>{{ blog.title }}</td> <!-- 変更 ④-->
            <td>{{ blog.body }}</td> <!-- 変更 ④-->
            <td>show</td>
            <td>Edit</td>
            <td>Destroy</td>
          </tr>
        </table>
      </v-col>
    </v-row>
  </div>
</template>

<script><img width="940" alt="スクリーンショット 2020-04-17 17.29.33.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/416999/7ce646f0-1161-06fe-4200-db60b9b4991f.png">

import { mapState } from 'vuex' //変更 ①

export default {
  computed: { //変更 ②
    ...mapState(['blogs'])
  },
  data() {
    return {
      blog: {}
    }
  },
  methods: {
    onSubmit() {
    }
  }
}
</script> 

①〜④の順にデータが移動します。

  1. vuexに簡単にアクセスできるようにする記述
  2. src/store/index.jsの中のstate/blogsを取得し、利用できるようにする
  3. v-for="blog in blogs"で②で取得したblogsのレコードを一つずつblogとして展開する
  4. blog.titleとすることでレコード上のtitleが一つずつ表示される

ここまでで http://0.0.0.0:3000/v1/blogs にアクセスすると以下の画像のような状態に(僕のは使いまわしているのでid: 66となってますが、id: 1があればOKです)。
スクリーンショット 2020-04-17 17.29.33.png

http://localhost:8080/#/blogs にアクセスすると以下のようになります。
スクリーンショット 2020-04-17 17.29.26.png

ここまででサーバーと通信してデータを取得し、vue側で表示できるようになりました。
次はブログを投稿できるようにしていきましょう。

ブログを投稿できるようにする

まずは投稿フォームを単一コンポーネントに切り出します。

src/views/AddBlog.vue
<template>
  <form>
    <v-text-field
      v-model="blog.title"
      label="Title"
    ></v-text-field>
    <v-textarea
      v-model="blog.body"
      label="Body"
    ></v-textarea>

    <v-btn class="mr-4" @click="onSubmit">Create</v-btn>
  </form>
</template>

<script>
export default {
  data() {
    return {
      blog: {},
    }
  },
  methods: {
    onSubmit() {
    }
  }
}
</script>

Blogs.vueにあったコードを切り取ってきます。

これをBlogs.vueにimportします。

src/views/Blogs.vue
<template>
  <div>
    <h1>Blogs</h1>
    <v-row>
      <v-col cols="4">
        <AddBlog /> <!-- 追加 -->
      </v-col>
      <v-col cols="8">
        <table>
          <tr>
            <th>Title</th>
            <th>Description</th>
          </tr>
          <tr v-for="blog in blogs" :key="blog.id">
            <td>{{ blog.title }}</td>
            <td>{{ blog.body }}</td>
            <td>show</td>
            <td>Edit</td>
            <td>Destroy</td>
          </tr>
        </table>
      </v-col>
    </v-row>
  </div>
</template>

<script>
import { mapState } from 'vuex'
import AddBlog from './AddBlog' //追加

export default {
  components: {
    AddBlog  //追加
  },
  computed: {
    ...mapState(['blogs'])
  },
  data() {
    return {
      blog: {}
    }
  },
  methods: {
    onSubmit() {
    }
  }
}
</script> 

コンポーネントを分けただけなので http://localhost:8080/#/blogs にアクセスしても特に変わりはないかと思います。

ではvuexを使って投稿機能を作っていきましょう。
まずはcreateボタンを押したらvuexのaddBlogアクションにアクセスするようにAddBlog.vue`を書き換えていきます。

src/views/AddBlog.vue
<template>
  <form>
    <v-text-field
      v-model="blog.title"
      label="Title"
    ></v-text-field>
    <v-textarea
      v-model="blog.body"
      label="Body"
    ></v-textarea>

    <v-btn class="mr-4" @click="onSubmit">Create</v-btn>
  </form>
</template>

<script>
export default {
  data() {
    return {
      blog: {},
    }
  },
  methods: {
    async onSubmit() {
      await this.$store.dispatch('addBlog', this.blog) //追加
    }
  }
}
</script>

this.$store.dispatchで第一引数(addBlog)に指定したvuexのアクションに第二引数(this.blog)の情報を渡します。
ここのthis.blogはdataの中に入っているtitleとbodyです。

ではvuexにaddBlogアクションを作っていきます。

src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import axios from '@/api/index'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    blogs: []
  },
  mutations: {
    FETCH_BLOGS(state, blogs) {
      state.blogs = blogs
    },
    ADD_BLOG(state, blog) { // 追加 ③第一引数はstate、第二引数は②でコミットされたsavedBlogが渡ってくる
      const blogs = state.blogs.concat(blog)
      state.blogs = blogs
    }
  },
  actions: {
    async fetchBlogs({ commit }) {
      await axios().get('/blogs')
        .then(res => {
          commit('FETCH_BLOGS', res.data)
        })
        .catch(e => console.log(e))
    },
    async addBlog({ commit }, blog) { // 追加 ① 、第二引数blogに先ほどのthis.blogが入る
      const res = await axios().post('/blogs', blog)
      const savedBlog = res.data
      commit('ADD_BLOG', savedBlog) // ②
      return savedBlog
    },
  }
})
  1. AddBlog.vueからアクセスされたactions addBlogにデータを送信
  2. ADD_BLOG mutationsにコミット
  3. stateblogsに渡されたデータを入れる

ここまでで以下のようになればOKです。
ezgif.com-video-to-gif (1).gif

投稿したあともフォームにデータが残るのは嫌なので、以下のように追記します。

/src/views/AddBlog.vue

<script>
export default {
  data() {
    return {
      blog: {},
    }
  },
  methods: {
    async onSubmit() {
      await this.$store.dispatch('addBlog', this.blog)
      this.blog.title = '' //追記
      this.blog.body = '' //追記
    }
  }
}
</script>

これで投稿したらフォームの中身がリセットされるようになりました。

投稿時に投稿詳細ページに遷移する

現在までのアプリだと投稿したらその場でBlogs.vueにデータが追加されてテーブルのレコードが増えていくという感じです。
正直、他のvueのサンプルアプリはこんなのばっかりだったので、今回は投稿時に個別の詳細ページに遷移させてみたいと思います。

以下の流れで作成します。

  1. ルーティング作成
  2. AddBlog.vueに投稿時に遷移するロジックを追記
  3. 投稿詳細ページを作成

ではルーティングから書いていきます。

store/router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
import Blogs from '../views/Blogs.vue' 
import Blog from '../views/Blog.vue' // 追加

Vue.use(VueRouter)

  const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/blogs',    
    name: 'blogs',
    component: Blogs
  },
  {
    path: '/blogs/:id',  // 追加
    name: 'show-blog',
    component: Blog,
    params: true
  }
  
]

const router = new VueRouter({
  routes
})

export default router

params: trueとすることで投稿時に取得したidを埋め込み動的なルーティングになります。

次にAddBlog.vueに追記していきます。

src/views/AddBlog.vue
<script>
export default {
  data() {
    return {
      blog: {},
    }
  },
  methods: {
    async onSubmit() {
      const blog = await this.$store.dispatch('addBlog', this.blog) // 変更
      this.$router.push({ name: 'show-blog', params: { id: blog.id }}) // 追記
    }
  }
}
</script>

this.$router.pushとすることで指定したルーティング先のページに飛びます。

これはちょっとややこしいですが、以下のようにreturnすることで作られたばかりのデータの情報を返してくれているので実行できています。

js/src/store/index.js

async addBlog({ commit }, blog) {
      const res = await axios().post('/blogs', blog)
      const savedBlog = res.data
      commit('ADD_BLOG', savedBlog)
      return savedBlog // この部分でpostされたデータを返している
}

では実際に詳細ページであるBlog.vueを作成します。

src/views/Blog.vue
<template>
  <div>
    <p>Title: {{ blog.title }}</p>  <!-- ④ -->
    <p>Body: {{ blog.body }}</p>
    <router-link to="/blogs">Back</router-link>
  </div>
</template>

<script>
import { mapState } from 'vuex' // ①

export default {
  computed: {
    ...mapState(['blogs']), // ②
    blog() { // ③
      return this.blogs.find(blogId => blogId.id === this.$route.params.id) || {}  
    }
  }
}
</script>
  1. storeの情報を簡潔にかけるようにmapStateをimport
  2. state/blogsを展開(this.blogsとして使える)
  3. path: '/blogs/:id'にマッチする個別情報をstate/blogsの中から見つけてblog()として関数化する
  4. ③のblogを展開し、個別のtitle,bodyを表示

ここまでで投稿したら詳細ページに遷移するようになりました。
ezgif.com-video-to-gif (2).gif

次は編集機能、削除機能をつけていきます。

編集機能を作る

投稿を編集できるようにします。

  1. 編集用のルーティングを作成
  2. 編集ページを作成
  3. vuex内で編集のロジックを追記

まずはルーティングから書いていきましょう。

src/router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
import Blogs from '../views/Blogs.vue'
import Blog from '../views/Blog.vue'
import EditBlog from '../views/EditBlog.vue' // 追加

Vue.use(VueRouter)

  const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/blogs',
    name: 'blogs',
    component: Blogs
  },
  {
    path: '/blogs/:id',
    name: 'show-blog',
    component: Blog,
    params: true
  },
  {
    path: '/blogs/:id/edit', // 追加
    name: 'edit-blog',
    component: EditBlog,
    params: true
  }
]

const router = new VueRouter({
  routes
})

export default router

詳細ページの時とほとんど同じですね。

次にBlog.vueBlogs.vueeditボタンを書いていきます。

src/views/Blog.vue
<template>
  <div>
    <Flash />
    <p>Title: {{ blog.title }}</p>
    <p>Body: {{ blog.body }}</p> <!-- 追加↓ -->
    <router-link :to="{ name: 'edit-blog', params: { id: blog.id }}">Edit</router-link> | <router-link to="/blogs">Back</router-link>
  </div>
</template>
vue/src/views/Blogs.vue
<template>
  <div>
    <Flash />
    <h1>Blogs</h1>
    <v-row>
      <v-col cols="4">
        <AddBlog />
      </v-col>
      <v-col cols="8">
        <table>
          <tr>
            <th>Title</th>
            <th>Description</th>
          </tr>
          <tr v-for="blog in blogs" :key="blog.id">
            <td>{{ blog.title }}</td>
            <td>{{ blog.body }}</td> <!-- 追加↓ -->
            <td><router-link :to="{ name: 'show-blog', params: { id: blog.id }}">show</router-link></td>
            <td><router-link :to="{ name: 'edit-blog', params: { id: blog.id }}">edit</router-link></td>
            <td>delete</td>
          </tr>
        </table>
      </v-col>
    </v-row>
  </div>
</template>

ついでに詳細ページへのリンクも加えておきましょう。

続いて編集ページを作っていきます。

src/views/EditBlog.vue
<template>
  <div>
    <h2>Editing Blog</h2>
    <form>
      <v-text-field
        v-model="blog.title"
        label="Title"
      ></v-text-field>
      <v-textarea
        v-model="blog.body"
        label="Body"
      ></v-textarea>

      <v-btn class="mr-4" @click="updateBlog">Update</v-btn>
    </form>
  </div>
</template>

<script>
import { mapState } from 'vuex'

export default {
  computed: {
    ...mapState(['blogs']),
    blog() {
      return this.blogs.find(b => b.id == this.$route.params.id) || {}
    }
  },
  methods: {
    async updateBlog() {
      const blog = await this.$store.dispatch('editBlog', this.blog) // ①
      this.$router.push({ name: 'show-blog', params: { id: blog.id }})
    }
  }
}
</script>

この時点で気付いたのですが、formの部分とかは共通なのでコンポーネント分けした方が良いです笑

今回はこのままいかせてください。

ここでは①の部分でstoreの中のeditBlogアクションに編集した内容でthis.blogを送ってます。

ではそのeditBlogアクションをstore/index.jsの中に作っていきましょう。

/src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import axios from '@/api/index'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    blogs: []
  },
  mutations: {
    FETCH_BLOGS(state, blogs) {
      state.blogs = blogs
    },
    ADD_BLOG(state, blog) {
      const blogs = state.blogs.concat(blog)
      state.blogs = blogs
    },
    EDIT_BLOG(state, blog) {  // 追記
      state.blogs.forEach(b => {
        if (b.id === blog.id) {
          b = blog
        }
      })
    }
  },
  actions: {
    async fetchBlogs({ commit }) {
      await axios().get('/blogs')
        .then(res => {
          commit('FETCH_BLOGS', res.data)
        })
        .catch(e => console.log(e))
    },
    async addBlog({ commit }, blog) {
      const res = await axios().post('/blogs', blog)
      const savedBlog = res.data
      commit('ADD_BLOG', savedBlog)
      return savedBlog
    },
    async editBlog({ commit }, blog) { // 追記
      const res = await axios().put(`/blogs/${blog.id}`, blog)
      const editedBlog = res.data
      commit('EDIT_BLOG', editedBlog)
      return editedBlog
    }
  }
})

axios().putとすることでサーバーに対して更新の処理を行うリクエストを送ります。

そしてmutasionsではstate.blogsの中で、送られてきたIDと合致するもののblogを書き換えるロジックを書いています。

詳細ページにもeditボタンを追加しておきましょう。

src/views/Blog.vue
<template>
  <div>
    <p>Title: {{ blog.title }}</p>
    <p>Body: {{ blog.body }}</p>
    <router-link :to="{ name: 'edit-blog', params: { id: blog.id }}">edit</router-link> | <router-link to="/blogs">Back</router-link> <!-- 追加 -->
  </div>
</template>

ここまでで編集機能ができました。
ezgif.com-video-to-gif (3).gif

次に削除機能を作っていきましょう。

投稿削除機能を作る

投稿したブログを削除する機能を作っていきます。

  1. 削除ボタンを作る
  2. vuexに削除のロジックをかく

削除ボタンはBlogs.vueに書くことにします。

src/views/Blogs.vue
<template>
  <div>
    <h1>Blogs</h1>
    <v-row>
      <v-col cols="4">
        <AddBlog />
      </v-col>
      <v-col cols="8">
        <table>
          <tr>
            <th>Title</th>
            <th>Description</th>
          </tr>
          <tr v-for="blog in blogs" :key="blog.id">
            <td>{{ blog.title }}</td>
            <td>{{ blog.body }}</td>
            <td><router-link class="button_link" :to="{ name: 'show-blog', params: { id: blog.id }}">[ show ]</router-link></td>
            <td><router-link class="button_link" :to="{ name: 'edit-blog', params: { id: blog.id }}">[ edit ]</router-link></td>
            <td><span class="button_link" @click="deleteBlog(blog)">[ delete ]</span></td> <!-- 追加 -->
          </tr>
        </table>
      </v-col>
    </v-row>
  </div>
</template>

<script>
import { mapState } from 'vuex'
import AddBlog from './AddBlog'

export default {
  components: {
    AddBlog
  },
  computed: {
    ...mapState(['blogs'])
  },
  data() {
    return {
      blog: {}
    }
  },
  methods: {
    deleteBlog(blog) { // 追加
      this.$store.dispatch('deleteBlog', blog)
    }
  }
}
</script> 

<style scoped>
.button_link {
  cursor: pointer;
  color: blue;
  text-decoration: underline;
}
</style>

今回はアクションに引数blogを渡してあげています。
v-forで回したうちの一つの情報を持っています。

これをthis.$store.dispatch('deleteBlog', blog)の状態でvuexにアクセスして渡しています。

src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import axios from '@/api/index'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    blogs: [],
    flash_message: {
      status: false,
      message: ''
    }
  },
  mutations: {
    setMessage(state, payload) {
      state.flash_message = payload
    },
    FETCH_BLOGS(state, blogs) {
      state.blogs = blogs
    },
    ADD_BLOG(state, blog) {
      const blogs = state.blogs.concat(blog)
      state.blogs = blogs
    },
    EDIT_BLOG(state, blog) {
      state.blogs.forEach(b => {
        if (b.id === blog.id) {
          b = blog
        }
      })
    },
    DELETE_BLOG(state, blogId) { // 追加 ②
      const blogs = state.blogs.filter(b => b.id != blogId)
      state.blogs = blogs
    }
  },
  actions: {
    async fetchBlogs({ commit }) {
      await axios().get('/blogs')
        .then(res => {
          commit('FETCH_BLOGS', res.data)
        })
        .catch(e => console.log(e))
    },
    async addBlog({ commit }, blog) {
      const res = await axios().post('/blogs', blog)
      const savedBlog = res.data
      commit('ADD_BLOG', savedBlog)
      return savedBlog
    },
    async editBlog({ commit }, blog) {
      const res = await axios().put(`/blogs/${blog.id}`, blog)
      const editedBlog = res.data
      commit('EDIT_BLOG', editedBlog)
      return editedBlog
    },
    async deleteBlog({ commit }, blog) { // 追加 ①
      await axios().delete(`/blogs/${blog.id}`, blog)
      commit('DELETE_BLOG', blog.id)
    }
  }
})

axios().deleteで削除用のリクエストを飛ばします。

②ではstate.blogs.filter(b => b.id != blogId)でstate.blogs`の中から、送られてきたid以外のもので新しい配列を作って返しています。

送っているidは削除ボタンを押した要素のidなので、それ以外で配列を作っているということです。

ここまでで削除機能ができました。

基本的な機能はこれで完成です。
あとはvalidationとアクション成功時のflashメッセージを作っていきます。

validationを作成する

新規投稿、編集時にフォームが空だった場合、送信できないようにします。

src/views/AddBlog.vue
<template>
  <v-form ref="checkForm">
    <v-text-field
      v-model="blog.title"
      label="Title"
      :rules="[required('Title')]"
    ></v-text-field>
    <v-textarea
      v-model="blog.body"
      label="Body"
      :rules="[required('Body')]"
    ></v-textarea>

    <v-btn class="mr-4" @click="onSubmit">Create</v-btn>
  </v-form>
</template>

<script>
export default {
  data() {
    return {
      blog: {},
      required(propertyType) { 
        return v => v && v.length > 0 || `You must input a ${propertyType}`
      }
    }
  },
  methods: {
    async onSubmit() {
      if (this.$refs.checkForm.validate()) {
        const blog = await this.$store.dispatch('addBlog', this.blog)
        this.$router.push({ name: 'show-blog', params: { id: blog.id }})
      }
    }
  }
}
</script>

これはcssフレークーワークvuetifyの記述方法を使っています。
EditBlog.vueも全く同じように記述します。

これでvalidationは完成です。
ezgif.com-video-to-gif (4).gif

最後に投稿や編集、削除が成功した際にサクセスメッセージを出すようにしていきましょう。

Flashメッセージを作成する

  1. vuexでメッセージの状態を管理できるようにする
  2. Flash.vueを作成
  3. Flash.vueをメッセージを表示させたいページにimport(今回はBlog.vue、Blogs.vue)
src/store/index.js
state: {
    blogs: [],
    flash_message: { // 追加
      status: false,
      message: ''
    }
  },
  mutations: {
    setMessage(state, payload) { // 追加
      state.flash_message = payload
    },
src/components/Flash.vue
<template>
  <transition name="fade">
    <div v-if="flash.status" class="success mb-3">{{ flash.message }}</div>
  </transition>
</template>

<script>
export default {
  computed: {
    flash() {
      return this.$store.state.flash_message
    }
  }
}
</script>

<style scoped>
.success {
  color: green;
}
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.5s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
  opacity: 0;
}
</style>

まずは投稿時にサクセスメッセージをtrueにし、メッセージをvuexにコミットしておきます。

src/views/AddBlog.vue
methods: {
    async onSubmit() {
      if (this.$refs.checkForm.validate()) {
        const blog = await this.$store.dispatch('addBlog', this.blog)
        this.$store.commit('setMessage', {
          status: true,
          message: 'Blog was successfully created.'
        })
        setTimeout(() => {
          this.$store.commit('setMessage', {})
        }, 2000)
        this.$router.push({ name: 'show-blog', params: { id: blog.id }})
      }
    }
  }

次にAddBlog.vueから投稿した際にBlog.vueでサクセスメッセージを表示できるようにします。

src/views/Blog.vue
<template>
  <div>
    <Flash /> // 追加
    <p>Title: {{ blog.title }}</p>
    <p>Body: {{ blog.body }}</p>
    <router-link :to="{ name: 'edit-blog', params: { id: blog.id }}">edit</router-link> | <router-link to="/blogs">Back</router-link>
  </div>
</template>

<script>
import { mapState } from 'vuex'
import Flash from '@/components/Flash.vue' // 追加

export default {
  components: { // 追加
    Flash
  },
  computed: {
    ...mapState(['blogs']),
    blog() {
      return this.blogs.find(blogId => blogId.id === this.$route.params.id) || {}
    }
  }
}
</script>

Blogs.vueにも記述しておきましょう。

src/views/Blogs.vue
<template>
  <div>
    <Flash /> // 追加
    <h1>Blogs</h1>
    <v-row>
      <v-col cols="4">
        <AddBlog />
      </v-col>
      <v-col cols="8">
        <table>
          <tr>
            <th>Title</th>
            <th>Description</th>
          </tr>
          <tr v-for="blog in blogs" :key="blog.id">
            <td>{{ blog.title }}</td>
            <td>{{ blog.body }}</td>
            <td><router-link class="button_link" :to="{ name: 'show-blog', params: { id: blog.id }}">[ show ]</router-link></td>
            <td><router-link class="button_link" :to="{ name: 'edit-blog', params: { id: blog.id }}">[ edit ]</router-link></td>
            <td><span class="button_link" @click="deleteBlog(blog)">[ delete ]</span></td>
          </tr>
        </table>
      </v-col>
    </v-row>
  </div>
</template>

<script>
import { mapState } from 'vuex'
import AddBlog from './AddBlog'
import Flash from '@/components/Flash.vue' // 追加

export default {
  components: {
    AddBlog,
    Flash // 追加
  },
  computed: {
    ...mapState(['blogs'])
  },
  data() {
    return {
      blog: {}
    }
  },
  methods: {
    deleteBlog(blog) { // 追加
      this.$store.dispatch('deleteBlog', blog)
      this.$store.commit('setMessage', {
        status: true,
        message: 'Blog was successfully destroyed.'
      })
      setTimeout(() => {
        this.$store.commit('setMessage', {})
      }, 2000)
    }
  }
}
</script> 

<style scoped>
.button_link {
  cursor: pointer;
  color: blue;
  text-decoration: underline;
}
</style>

ezgif.com-video-to-gif (5).gif

以上で完成です。デプロイする気力はありませんでした。

お付き合いいただきありがとうございます、お疲れ様でした!!

今回のコード

Railsの方 → https://github.com/haga0531/BlogAppApi
Vueの方 → https://github.com/haga0531/blog-app-vue
(なんかアプリ作りながら記事書いていたらcommit数一回というゴミみたいな危機管理能力になっていたのは許してください)

まとめ

どの記事もtodoアプリばっかりだったので少しだけレベルアップしたアプリを作ってvuex、vue-routerの練習をしてみました。

コンポーネントの分け方は甘々だし、用語の使い方がゴチャゴチャなことも多くあると思います。
ドMなので厳し目にコメントくださると画面の前で土下座しながら修正させていただくので、ぜひご指摘のほどよろしくお願いします。

いや〜、しかし書いてて超楽しいですね、vue。

今後は業務でも使用しているNuxt.jsFirebaseJavaなどにも取り組んでいきます。
次は認証ありのアプリを作ってみます。

参考にさせていただいた神記事たち

[入門]Rails API × Nuxt SPA × Firebase Authで作る Todo Appチュートリアル

インターン先の超尊敬している先輩の記事です。
この記事をみながらQiita独特の書き方的なのを学びました。」ちなみに上記チュートリアルは僕自身3周しました。神でした。僕の記事読むよりこっち読んでください。

Vue & Vuetifyでバリデーション付きのフォームを作ってみる

vuetifyを使ったvalidationの書き方を参考にさせていただきました。

78
90
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
78
90

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?