scaffoldを使うとCRUDが揃った「土台」を一発で作れるわけですが、それをアレコレして全ての操作をAjax化してみたので、手順をまとめました。

記事を読むのがだりぃって方はソースコードをGitHubで公開してるので、そちらをご覧ください。

RubyもRailsもあんまり触った事がないので、識者によるツッコミ歓迎します。 (`・ω・´)

概要

やること

indexの画面だけでCRUD、つまり新規作成 (Create)、表示 (Read)、編集 (Update)、削除 (Delete)を行えるよう、scaffoldで作成したファイルをいじります。

結論

form_for()remote: trueを与えるだけで、とりあえずAjax化します。あとはサーバー側のレスポンスの内容を整えて、クライアント側で適切に処理してやればOKです。

作業

  1. 下準備(scaffoldとか)
  2. indexに編集フォームを埋め込み
  3. 編集フォームをAjax化
  4. Ajaxで動く、新規作成フォームを作成
  5. Ajaxで動く、削除ボタンを作成

ソースコード

GitHubで公開しています。段階事にコミットしているので、各コミットの差分を見るとわかりやすいと思います。

環境

  • ruby 1.9.2
  • Rails 3.1.2
  • jQuery 1.7.1

jQuery 1.7で追加された .on()を使ってるんで、1.6とかだと動かないです。まあ勝手に最新のものが入るはずですけど。

本記事を読む上での注意点

編集の都合によりインデントが欠落した部分があります。CoffeeScriptではインデントが意味を持っているので、コピペだとその場合動かないどころかエラーになります。適宜補完してください。

SyntaxHighlighter Evolvedを使ってるんですけど、どうにかならないですかね?)

下準備

ここは解説省略。

rails new memobook
cd memobook
rails g scaffold memo body:string
rake db:migrate

http://localhost:3000/memosとかそこらを開くと、空の一覧が表示されます。今からコードをいじったらしばらく作成画面にアクセスできなくなるので、適当に幾つかデータを追加しといてください。

indexに編集フォームを埋め込み

一覧の画面に変更フォームを追加します。既存のボタン類はいらないから削除。というかがっつり書き換えます。あと、「表示モード」と「編集モード」を切り替える事を想定。

フォーム構造

app/views/memos/index.html.erb

<h1>Listing memos</h1>

<div id="memos">
<% @memos.each do |memo| %>
  <div class="memo">
    <div class="viewer">
      <span class="body"><%= memo.body %></span>
      <%= button_tag 'Edit', class: 'edit', type: :button %>
    </div>
    <div class="editor">
      <div>
        <%= button_tag 'Cancel', class: 'cancel', type: :button %>
      </div>
      <%= render 'form', memo: memo %>
    </div>
  </div>
<% end %>
</div>

<br />

<%= link_to 'New Memo', new_memo_path %>

app/views/memos/_form.html.erb

<%= form_for memo do |f| %>
  <%= f.text_field :body, id: nil, class: 'body' %>
  <%= f.submit 'Update' %>
<% end %>

一応動作確認してみましょうか。変更してUpdateすると、参照画面に遷移してMemo was successfully updated.と表示される。

Ajaxはまだだけど、とりあえずフォームはこれでよさそう。続いて表示を切り替えられるようにします。

フォーム操作

“Edit”ボタンと”Cancel”ボタンを押したとき、表示用の領域と編集用の領域をそれぞれ切り替えるようにする。

せっかくなので、CoffeeScript + jQueryです。普通のJavaScriptが良いって人は、ファイル名をmemos.jsに変更して、内容も適当に書き直してください。

app/assets/javascripts/memos.js.coffee

$ ->
  $('#memos')
    .on 'click', '.edit, .cancel', (event) ->
      # 表示を切り替え
      toggleEditor $(this).closest('.memo')

# 表示モードと編集モードを切り替える。
toggleEditor = ($container) ->
  # 表示、非表示を切り替え
  $container.find('.viewer, .editor').toggle()

  # 編集モードなら、値を戻す
  $bodyField = $container.find('.editor .body')
  if $bodyField.is(':visible')
    $bodyField
      .val($container.find('.viewer .body').text())
      .select()

あとスタイルシートもね。

app/assets/stylesheets/memos.css.scss

.memo {
  border: solid 1px #eee;
  margin: 10px;
  padding: 10px;
}
.memo .editor {
  display: none;
}

これでぱかぱか表示を切り替えられるようになりました。ちなみにこれはAjaxじゃなくてDHTMLと呼ばれます。

編集フォームをAjax化

Ajax化

フォームを非同期通信で処理するようにするには、form_for()remote: trueを与えるだけ。うひょー、簡単じゃね!?

app/views/memos/_form.html.erb

<%= form_for memo, remote: true do |f| %>

一応、これだけでフォームが「Ajax化」完了です。

試してみてください、ちゃんと画面遷移なしで値が更新されます!! ……しますけど、何も表示されないし手動で画面を再読み込みしないと値が変わったかどうかがわからない。再読み込みすると変わってるんだけどね。

まあとりあえずたったこれだけでAjax使えますよ、と。

さあさあ、ここからが大変だよ!

AjaxとはJavaScriptでXHRを使ってサーバーと非同期通信を行い、画面遷移なしで処理を完結させる事。(って事にしておいてください。) つまりこのあたりはJavaScriptで処理をあれこれ書きつつ、やりとり行うサーバー側のRubyもやっぱり書いてくって事になります。

ここからしばらくは、一歩ずつ進めてゆきます。結論だけ見たい人はちょっと飛ばしてね。

通信を拾う

とりあえず、具体的な処理は置いておいて、通信を拾ってみましょうか。ajax:completeというイベントがform要素で発火します。(仕組みは省略。)

あ、インデント気を付けてくださいね。

app/assets/javascripts/memos.js.coffee

$ ->
  $('#memos')
    .on 'click', '.edit, .cancel', (event) ->
      # 表示を切り替え
      toggleEditor $(this).closest('.memo')

    .on 'ajax:complete', '.edit_memo', (event, ajax, status) ->
      # 発火を確認
      alert status

これで、Updateしたらアラートが表示されるようになりました。通信に成功すると"success"が表示されます。

有意な情報を返すようにする

次はサーバーからデータを返して、変更後の値を表示してみましょう。通信先はform_forのオプションで変えられるけど、今回はデフォルトのmemos#updateが呼ばれてます。というわけで、そこのコードを修正します。

app/controllers/memos_controller.rb

  # PUT /memos/1
  # PUT /memos/1.json
  def update
    @memo = Memo.find(params[:id])

    if @memo.update_attributes(params[:memo])
      status = 'success'
    else
      status = 'error'
    end

    render json: { status: status, data: @memo }
  end

render({json: data})で、JSON形式で出力します。ここでは丸ごと返すようにしました。

するとHTTPレスポンスはこんな感じになります。(Firebugとかネットワーク覗く系のツールとかで確認できます。)

{"status":"success","data":{"body":"hoge","created_at":"2011-11-27T14:28:24Z","id":1,"updated_at":"2011-11-27T14:56:16Z"}}

レスポンスを解析して設定値を取得する

さて、受信側はJavaScript/CoffeeScriptです。上記のHTTPレスポンスは、このコードだとajax.responseTextに格納されています。

app/assets/javascripts/memos.js.coffee

    .on 'ajax:complete', '.edit_memo', (event, ajax, status) ->
      # HTTPレスポンスをそのまま表示
      alert ajax.responseText

あ、これインデントに気を付けてくださいね! 編集の都合上1文字目からになってますけど、ちゃんと4文字の空白を補完する必要がありますから気を付けてください。

で、これでレスポンスが表示されました。といってもそんな生のデータには用はないわけで、JSONデータを解析してJavaScriptのオブジェクトに変換します。jQuery.parseJSON()が便利です。

    .on 'ajax:complete', '.edit_memo', (event, ajax, status) ->
      response = $.parseJSON(ajax.responseText)
      body = response.data.body

      # 設定値をとりあえず表示
      alert body

処理結果を画面に反映させる

設定値が取得できたので、これで画面を更新してやりつつ、フォームは閉じてしまいましょう。

    .on 'ajax:complete', '.edit_memo', (event, ajax, status) ->
      response = $.parseJSON(ajax.responseText)
      body = response.data.body
      $container = $(this).closest('.memo')

      # 表示されてる値を更新
      $container.find('.viewer .body').text body

      # 表示を戻す
      toggleEditor $container

やたーできたよー!

まとめ

  • form_forremote: trueを与えるだけで、フォームがAjax化する。
  • updateアクションで結果を返す。
  • JavaScript/CoffeeScriptでレスポンスを解析して画面を書き換える。
  • レスポンスはrender({json: data})でJSON形式にして、jQuery.parseJSON(ajax.responseText)でオブジェクト化するのが簡単。

この調子で新規作成と削除も作ってみましょう!

Ajaxで動く、新規作成フォームを作成

ここからはざっくりさっくり行きますよ。

やること:

  • 新規作成フォームを追加
  • 項目を新規作成したら、結果を画面に追加(編集フォームも含む)
  • 画面にHTMLを追加する為に、HTTPレスポンスに追加HTMLを書き出す (render_to_string()を使います)
  • 項目ひとつ分を画面に出力するため、テンプレートを分割

app/views/memos/index.html.erb

<h1>Listing memos</h1>

<div id="memos">
<% @memos.each do |memo| %>
  <%= render 'show', memo: memo %>
<% end %>
</div>

<br />

<h1>New memo</h1>
<%= render 'form', memo: @new_memo, submit_text: 'Create' %>

app/views/memos/_show.html.erb (新規作成)

<div class="memo">
  <div class="viewer">
    <span class="body"><%= memo.body %></span>
    <%= button_tag 'Edit', class: 'edit', type: :button %>
  </div>
  <div class="editor">
    <div>
      <%= button_tag 'Cancel', class: 'cancel', type: :button %>
    </div>
    <%= render 'form', memo: memo, submit_text: 'Update' %>
  </div>
</div>

app/views/memos/_form.html.erb

<%= form_for memo, remote: true do |f| %>
  <%= f.text_field :body, id: nil, class: 'body' %>
  <%= f.submit submit_text %>
<% end %>

app/controllers/memos_controller.rb

  # GET /memos
  # GET /memos.json
  def index
    @memos = Memo.all
    @new_memo = Memo.new

    respond_to do |format|
      format.html # index.html.erb
      format.json { render json: @memos }
    end
  end

もいっちょ。

  # POST /memos
  # POST /memos.json
  def create
    @memo = Memo.new(params[:memo])

    if @memo.update_attributes(params[:memo])
      status = 'success'
      html = render_to_string partial: 'show', locals: { memo: @memo }
    else
      status = 'error'
    end

    render json: { status: status, data: @memo, html: html }
  end

app/assets/javascripts/memos.js.coffee

$ ->
  $('#memos')
    .on 'click', '.edit, .cancel', (event) ->
      # 表示を切り替え
      toggleEditor $(this).closest('.memo')

    .on 'ajax:complete', '.edit_memo', (event, ajax, status) ->
      response = $.parseJSON(ajax.responseText)
      body = response.data.body
      $container = $(this).closest('.memo')

      # 表示されてる値を更新
      $container.find('.viewer .body').text body

      # 表示を戻す
      toggleEditor $container

  $('#new_memo')
    .on 'ajax:complete', (event, ajax, status) ->
      response = $.parseJSON(ajax.responseText)
      html = response.html

      # 画面に追加
      $('#memos').append html

      # フォームを初期化
      $(this)[0].reset()

# 表示モードと編集モードを切り替える。
toggleEditor = ($container) ->
  # 表示、非表示を切り替え
  $container.find('.viewer, .editor').toggle()

  # 編集モードなら、値を戻す
  $bodyField = $container.find('.editor .body')
  if $bodyField.is(':visible')
    $bodyField
      .val($container.find('.viewer .body').text())
      .select()

Ajaxで動く、削除ボタンを作成

form_for()method: :deleteを指定するのがミソ……なんだけど、正直このやり方でいいのかわかりません。

app/views/memos/_show.html.erb

<div class="memo">
  <div class="viewer">
    <span class="body"><%= memo.body %></span>
    <%= button_tag 'Edit', class: 'edit', type: :button %>
  </div>
  <div class="editor">
    <div>
      <%= button_tag 'Cancel', class: 'cancel', type: :button %>
    </div>
    <%= render 'form', memo: memo, submit_text: 'Update' %>
    <%= form_for memo, method: :delete, remote: true, html: { id: nil, class: 'delete_memo' } do |f| %>
      <%= f.submit 'Destroy' %>
    <% end %>
  </div>
</div>

app/controllers/memos_controller.rb

  # DELETE /memos/1
  # DELETE /memos/1.json
  def destroy
    @memo = Memo.find(params[:id])
    @memo.destroy

    render json: { status: 'success', data: @memo }
  end

app/assets/javascripts/memos.js.coffee

  $('#memos')
    .on 'click', '.edit, .cancel', (event) ->
      # 表示を切り替え
      toggleEditor $(this).closest('.memo')

    .on 'ajax:complete', '.edit_memo', (event, ajax, status) ->
      response = $.parseJSON(ajax.responseText)
      body = response.data.body
      $container = $(this).closest('.memo')

      # 表示されてる値を更新
      $container.find('.viewer .body').text body

      # 表示を戻す
      toggleEditor $container

    .on 'ajax:complete', '.delete_memo', (event, ajax, status) ->
      # 項目を削除
      $(this).closest('.memo').remove()

これでCRUD完成、ばんざーい。 ∩(・ω・)∩

それからそれから

あとは使ってないviewとかactionは削除しちゃっていいですね。

デザインはもうちょっと頑張りましょう……僕はセンスないのでこれでいいです。

それとエラー処理とテストもきっちりやるようにしたらいいんじゃないかなと思います。はい。

おまけ

Error: Parse error on line XXX: Unexpected 'INDENT'って言われたら

読み込んでいるCoffeeScriptのインデントがおかしくて、コードの解析に失敗したみたいです。(CoffeeScript自体ではなく、それを読み込んでいるviewの方で表示されます。) 行番号が表示されているので、そこを見直してみてください。

CoffeeScriptはブロックを中括弧(braces){ ... }じゃなくてインデントで見ています。Pythonもそうですね。

render()じゃなくてもっと高度なJSONを返したい

app/view/memos/update.js.coffee.erbみたいな名前でテンプレートを作成しておくと、引数なしでrender()を呼んだ時に読み込んでくれるみたいです。

{"status":"<%= @status %>"}

callbackを指定してJSONPにしたい

テンプレートを使えばいくらでもできるけど、なんかもっとエレガントなやり方があるんじゃないかなあ。

<%= @callback_name %>({"status":"<%= @status %>"})

誰か教えてー。 _(・ω・`_)⌒)_

参考