Rails+Vue.jsによるフォームの作例

<font color="gray">※この記事は、まとめ記事「多人数によるRailsアプリケーション開発」の1項目にする予定です。</font>

私の体感では、Railsアプリケーションで開発にかかる時間の半分はテンプレートとJavaScriptで、その大半はフォームです。ややこしいテンプレートはRails側で頑張らずに、Vue.jsのようなJavaScriptのフレームワークに投げてしまう、という作り方を今後のスタイルとしたい。

このサンプルは、ここ1年半ほどRails上でVue.jsをいじくって考えた、現在のところのベターなパターンです。まだ研究中なところもあり、今後変更する可能性もあります。

サンプルプログラムはこちら。簡単なブログアプリケーションです。
https://github.com/kazubon/blog-rails6-vuejs

2020-02-24: CSRF対策のトークンを入れる部分を共通化。
2020-02-02: 最新のコードに合わせて記事を修正しました。
2019-12-25: 検索パラメータに付けていたキー`q`はやめました。

## 環境

- Rails 5.2/6.0、Webpacker 4、Vue.js 2.6。
- 非SPA、Turbolinksあり。
- jQueryとBootstrapあり。

そこそこ大きな業務アプリケーションを想定（サンプルはブログですが）。Vue.jsではないJavaScriptを使っているなど、いろいろなスタイルのページが混じっているものとする。

## ポイント

- newとeditでは、2回リクエストを送る。1回目はふつうのHTMLで、ページの枠だけ受け取る。2回目はVueからAjaxでモデルのデータを受け取り、フォームの入力欄に反映する。
- createとupdateは、Ajaxで呼ぶ。保存に成功したときはJavaScriptでリダイレクトし、失敗したらエラーメッセージを表示する。
- 検索結果の表示ページでも、2回リクエストを送る。1回目はふつうのHTMLで、ページの枠だけ。2回目はVueからAjaxで検索のパラメータを送って記事リストを受け取り、一覧を表示する。

関連記事:

- [RailsにWebpackerでVue.jsを入れたら行う設定](https://qiita.com/kazutosato/items/c0e508f15b9a03f3dd6b)
- [RailsからVueにデータを渡すには](https://qiita.com/kazutosato/items/66bb6604bd680421101d)
- [バリデーションのエラー表示をなるべく出さない](https://qiita.com/kazutosato/items/141d9880097fd51d7a37)
- [Rails+Reactによるフォームの作例](https://qiita.com/kazutosato/items/b046ea203533af08acdb)

## application.js

packs下のapplication.jsの書き方はいろいろ考えられますが、このサンプルではこんな感じです。HTML要素をid属性で探して、対応するVueアプリケーションをマウントします。

グローバル変数jsPropsは、Railsから直接データをVueのpropsに渡すのに使っています。

SessionForm（ログインフォーム、この記事では紹介なし）だけ.vueファイル内のテンプレートを使わずにRailsが出したHTMLをパースしていますが、これは比較研究したかっただけです。

```javascript:app/javascript/packs/application.js
import "core-js/stable/promise";

require("@rails/ujs").start();
require("turbolinks").start();

import Vue from 'vue';
import TurbolinksAdapter from 'vue-turbolinks';

import EntryIndex from '../entries/index';
import EntryForm from '../entries/form';
import EntryStar from '../entries/star';
import SessionForm from '../sessions/form';
import Flash from '../flash';
import '../axios_config';

Vue.use(TurbolinksAdapter);

document.addEventListener('turbolinks:load', () => {
  Flash.show();

  let apps = [
    { elem: '#entry-index', object: EntryIndex },
    { elem: '#entry-form', object: EntryForm },
    { elem: '#entry-star', object: EntryStar },
    { elem: '#session-form', object: SessionForm }
  ];

  let props = window.jsProps || {};
  apps.forEach((app) => {
    if($(app.elem).length) {
      if(app.object.render) { // テンプレートあり
        new Vue({ el: app.elem, render: h => h(app.object, { props }) });
      }
      else { // HTMLをテンプレートに
        new Vue(app.object).$mount(app.elem);
      }
    }
  });
});
```

## newとedit

編集ページのアクションです。HTMLの枠とAjaxでのデータ送信を同じアクションにしていますが、Ajax用を分けて`api/entries_controller.rb`のような別コントローラにすることも考えられます。

`Entries::Form`は形式的に置いているもので、このサンプルの編集ページでは使ってません。

```ruby:app/controllers/entries_controller.rb
  def new
    @entry = Entry.new
    @form = Entries::Form.new(current_user, @entry)
    respond_to do |format|
      format.html
      format.json { render :edit }
    end
  end

  def edit
    @entry = current_user.entries.find(params[:id])
    @form = Entries::Form.new(current_user, @entry)
    respond_to :html, :json
  end
```

編集ページのHTML枠です。editではグローバル変数jsPropsでEntryモデルのidを渡しています。

```erb:app/views/entries/new.html.erb
<div id="entry-form"></div>
```

```erb:app/views/entries/edit.html.erb
<script>
var jsProps = <%= { entryId: @entry.try(:id) }.to_json.html_safe %>;
</script>
<div id="entry-form"></div>
```

編集ページのフォーム用のVueです。createdでAjaxを使ってEntryモデルのデータを取得し、フォームにセットします。

```vue:app/javascript/entries/form.vue
<template>
  <div>
    <form @submit="submit">
      <div v-if="alert" class="alert alert-danger">{{alert}}</div>
      <div class="form-group">
        <label for="entry-title">タイトル</label>
        <input type="text" v-model="entry.title" id="entry-title"
          class="form-control" required maxlength="255" pattern=".*[^\s]+.*">
      </div>
      <div class="form-group">
        <label for="entry-body">本文</label>
        <textarea v-model="entry.body" id="entry-body" cols="80" rows="15"
          class="form-control" required maxlength="40000">
        </textarea>
      </div>
      <div class="form-group">
        <label for="entry-tag0">タグ</label>
        <div>
          <input v-for="(tag, index) in entry.tags" :key="index" v-model="tag.name"
            class="form-control width-auto d-inline-block mr-2" style="width: 17%"
            maxlength="255" >
        </div>
      </div>
      <div class="form-group">
        <label for="entry-published_at">日時</label>
        <input type="text" v-model="entry.published_at" id="entry-published_at"
          class="form-control"
          pattern="\d{4}(-|\/)\d{2}(-|\/)\d{2} +\d{2}:\d{2}">
      </div>
      <div class="form-group mb-4">
        <input type="checkbox" v-model="entry.draft" id="entry-draft" value="1">
        <label for="entry-draft">下書き</label>
      </div>
      <div class="row">
        <div class="col">
          <button type="submit" class="btn btn-outline-primary">{{entryId ? '更新' : '作成'}}</button>
        </div>
        <div class="col text-right" v-if="entryId">
          <button type="button" class="btn btn-outline-danger" @click="destroy">削除</button>
        </div>
      </div>
    </form>
  </div>
</template>

<script>
import Axios from 'axios';
import Flash from '../flash';

export default {
  props: ['entryId'],
  data() {
    return {
      entry: {},
      alert: null
    };
  },
  created() {
    Axios.get(this.path() + '.json').then((res) => {
      this.entry = res.data.entry;
      this.initTags();
    });
  },
  methods: {
    path() {
      return this.entryId ? `/entries/${this.entryId}/edit` : '/entries/new';
    },
    initTags() {
      let len = this.entry.tags.length;
      if(len < 5) {
        for(let i = 0; i < 5 - len; i++) {
          this.entry.tags.push({ name: '' });
        }
      }
    },
// 中略
  }
}
</script>
```

newとeditのアクションで、Vueに渡すjsonデータです。

```ruby:app/views/entries/edit.jbuilder
json.entry do
  json.title @entry.title
  json.body @entry.body
  json.published_at (@entry.published_at || Time.zone.now).strftime('%Y-%m-%d %H:%M')
  json.draft @entry.draft
  json.tags do
    json.array! @entry.tags do |tag|
      json.name tag.name
    end
  end
end
```

## createとupdate

新規作成と更新のアクションです。成功したときは、jsonでリダイレクト先のパスを返します。失敗したときは、ステータスコード422とメッセージを返します。

ここで使っている`Entries::Form`は、バリデーションとデータの保存を行うものです。別記事「[フォームクラスを使う](https://qiita.com/kazutosato/items/70c2e3549d86c0293b20)」で紹介しています。

```ruby:app/controllers/entries_controller.rb
  def create
    @entry = Entry.new
    @form = Entries::Form.new(current_user, @entry, entry_params)
    if @form.save
      render json: { location: entry_path(@entry), notice: '記事を作成しました。' }
    else
      render json: { alert: '記事を作成できませんでした。' },
        status: :unprocessable_entity
    end
  end

  def update
    @entry = current_user.entries.find(params[:id])
    @form = Entries::Form.new(current_user, @entry, entry_params)
    if @form.save
      render json: { location: entry_path(@entry), notice: '記事を更新しました。' }
    else
      render json: { alert: '記事を更新できませんでした。' },
        status: :unprocessable_entity
    end
  end
# 中略
  def entry_params
    params.require(:entry).permit(
      :title, :body, :published_at, :draft, tags: [ :name ]
    )
  end
```

編集ページのフォーム用のVueで、フォームを送信するメソッドです。成功したときは指定のパスにリダイレクトします。失敗したときはアラート表示部にメッセージを入れます。

meta属性からCSRF対策のトークンを取ってHTTPヘッダに入れる設定は、axios_config.jsにあります。別記事「[Rails+AxiosでCSRF対策用のトークンを使う設定](https://qiita.com/kazutosato/items/0ce670272489cb647fb3)」をごらんください。

```vue:app/javascript/entries/form.vue
<script>
export default {
// 中略
  methods: {
// 中略
    submitPath() {
      return this.entryId ? `/entries/${this.entryId}` : '/entries';
    },
    submit(evt) {
      evt.preventDefault();
      if(!this.validate()) {
        return;
      }
      Axios({
        method: this.entryId ? 'patch' : 'post',
        url: this.submitPath() + '.json',
        data: { entry: this.entry }
      }).then((res) => {
        Flash.set({ notice: res.data.notice });
        Turbolinks.visit(res.data.location);
      }).catch((error) => {
        if(error.response.status == 422) {
          this.alert = error.response.data.alert;
        }
        else {
          this.alert = `${error.response.status} ${error.response.statusText}`;
        }
        window.scrollTo(0, 0);
      });
    },
// 中略
  }
}
</script>
```

## index

検索フォームと記事一覧を表示するindexアクションです。ここでもHTMLの枠とAjaxのデータを同じアクションにしています。

ここで使っている`Entries::SearchForm`は、パラメータを使って検索を行うものです。これも[フォームクラスを使う](https://qiita.com/kazutosato/items/70c2e3549d86c0293b20#%E6%A4%9C%E7%B4%A2%E3%83%95%E3%82%A9%E3%83%BC%E3%83%A0)で紹介しています。

```ruby:app/controllers/entries_controller.rb
  def index
    @user = User.active.find(params[:user_id]) if params[:user_id].present?
    @form = Entries::SearchForm.new(current_user, @user, search_params)
    respond_to :html, :json
  end
（中略）
  def search_params
    params.except(:user_id, :format).permit(:title, :tag, :offset, :sort)
  end
```

検索ページのHTML枠の一部です。グロバール変数jsPropsでVueに検索パラメータを渡します。

```erb:app/views/entries/index.html.erb
<script>
var jsProps = <%= { query: @form.query }.to_json.html_safe %>;
</script>
<div id="entry-index"></div>
```

検索フォームと記事一覧用のVueです。フォーム（search_form.vue）と一覧（list.vue）を子コンポーネントに分けています。

```vue:app/javascript/entries/index.vue
<template>
  <div>
    <search-form :query="query"></search-form>
    <list :query="query"></list>
  </div>
</template>

<script>
import Form from './search_form';
import List from './list';

export default {
  props: ['query'],
  components: { 'search-form': Form, 'list': List }
}
</script>
```

検索フォームのVueです。ここでは特に何もしてません。タグを入力したときに動的に候補を出すような機能を付けるときはここに追加するつもりです。

検索フォームの送信はブラウザーに任せてページ遷移します。

```vue:app/javascript/entries/search_form.vue
<template>
  <div>
    <form action="/entries" method="get" class="form-inline mb-4">
      <input type="text" name="title" class="form-control mr-3 mb-2"
        v-model="title" placeholder="タイトル">
      <input type="text" name="tag" class="form-control mr-3 mb-2"
        v-model="tag" placeholder="タグ">
      <input type="hidden" name="sort" v-model="query.sort">
      <input type="hidden" name="user_id" v-model="query.user_id">
      <button type="submit" class="btn btn-outline-primary mb-2">検索</button>
    </form>
  </div>
</template>

<script>
export default {
  props: ['query'],
  data() {
    return {
      title: this.query.title,
      tag: this.query.tag
    };
  }
}
</script>
```

検索結果の一覧を出すVueです。createdでAjax送信を行い、記事一覧のデータを配列entriesに入れます。「もっと読む」ボタンを押したときは、オフセットを増やして記事を取得します。

```vue:app/javascript/entries/list.vue
<template>
  <div>
    <div class="text-right mb-3">
      {{entriesCount}}件 | 
      <a :href="sortPath('date')" v-if="query.sort == 'stars'">日付順</a>
      <template v-else>日付順</template>
      | <a :href="sortPath('stars')" v-if="query.sort != 'stars'">いいね順</a>
      <template v-else>いいね順</template>
    </div>
    <div class="entries mb-4">
      <div v-for="entry in entries" :key="entry.id" class="entry">
        <div>
          <a :href="entry.path">
            <template v-if="entry.draft">（下書き） </template>
            {{entry.title}}
          </a>
        </div>
        <div class="text-right text-secondary">
          <a :href="entry.user_path">{{entry.user_name}}</a> |
          <a v-for="tag in entry.tags" :key="tag.id" class="mr-2"
              :href="tag.tag_path">{{tag.name}}</a> |
          {{entry.published_at}} |
          <span class="text-warning" v-if="entry.stars_count > 0">★{{entry.stars_count}}</span>
        </div>
      </div>
    </div>
    <div v-if="showMore">
      <button type="button" @click="moreClicked" class="btn btn-outline-secondary w-100">もっと見る</button>
    </div>
  </div>
</template>

<script>
import Axios from 'axios';
import qs from 'qs';

export default {
  props: ['query'],
  data: function () {
    return {
      entries: [],
      entriesCount: 0,
      offset: 0
    };
  },
  computed: {
    showMore() {
      return (this.entries.length < this.entriesCount);
    }
  },
  created () {
    this.getEntries();
  },
  methods: {
    getEntries() {
      let params = { ...this.query, offset: this.offset };
      let path = '/entries.json?' + qs.stringify(params);
      Axios.get(path).then((res) => {
        this.entries = this.entries.concat(res.data.entries);
        this.entriesCount = res.data.entries_count;
      });
    },
    moreClicked() {
      this.offset += 20;
      this.getEntries();
    },
    sortPath(key) {
      let params = { ...this.query, sort: key };
      return '/entries?' + qs.stringify(params);
    }
  }
}
</script>
```

indexアクションで、検索結果を返すjsonです。`Entries::SearchForm`のメソッドを呼び出しています。

```ruby:app/views/entries/index.jbuilder
json.entries do
  json.array! @form.entries do |entry|
    json.id entry.id
    json.title entry.title
    json.path entry_path(entry)
    json.user_name entry.user.name
    json.user_path user_entries_path(entry.user)
    json.draft entry.draft?
    json.published_at entry.published_at.try(:strftime, '%Y-%m-%d %H:%M')
    json.stars_count entry.stars_count
    json.tags do
      json.array! entry.tags do |tag|
        json.id tag.id
        json.name tag.name
        json.tag_path(
          @user ? user_entries_path(@user, tag: tag.name) : entries_path(tag: tag.name)
        )
      end
    end
  end
end
json.entries_count @form.entries_count
```

## Vue.jsと関係ない補足

- createとupdateは、Ajaxで呼ぶほうが楽です。失敗時に`render :new`などでテンプレートを作り直す手間が省けるのは大きい。また、失敗ページでのブラウザーの進む/戻る/リロードの動作が自然になります。
- 検索フォームを送信するときは、GETメソッドでページ遷移させて、URLを変化させます。POSTメソッドを使ったりAjaxを使ったりすると、ブラウザーのリロードで検索結果が消えたり、検索結果をブックマークできなくなったりします。
- JavaScriptでフォームの送信を扱うときは、送信ボタンのclickイベントじゃなくて、form要素のsubmitイベントを処理すること。

## 検討課題

- Vueのテンプレートは.vueファイル内に入れるべきか、Railsが出力したHTMLを使うべきか。既存のアプリケーションでは、テンプレートをすべて.vueファイルに移すのは現実的に無理なので、両方ありとします。
- フォームのAjax送信にrails-ujs（`remote: true`を付けて`ajax:success`イベントを処理）を使うべきか。rails-ujsでもいいんだけど、慣れていない人に分かりにくいのでAxiosで統一したほうがよい。
- RailsからVueに直接データを渡す方法（このサンプルのグローバル変数jsProps）については、まだ迷い中。