26 December 2014

    jQuery,Ajax,CoffeeScript覚えること多過ぎ

     先物・オプションのシミュレーションサイトのポジション編集画面で、チェックボックスをクリックすることでグラフのタイプを切り替える処理(データ更新後リダイレクトしている)をしているのですが、これをAjaxで実現しようとしたところ思わぬエラーに遭遇したので、新規サンプルプロジェクトを作って改めて動作を確認することにしました。

    rails _4.1.6_ new chkbox                ←バージョンを指定して新規プロジェクト作成
    cd chkbox
    rails g scaffold memo body:string chk:boolean
    bundle install
    rake db:migrate
    rails s
    

    scaffold 編集画面

     あと、turbolinksを有効にしているとAjaxの動作状況が分かりにくいのでこのサイトを参考にしてturbolinksを無効にしました。

    #app/views/memos/_form.html.erb
     <%= form_for(@memo, remote: true) do |f| %>
       <div class="field">
         <%= f.label :body %><br>
         <%= f.text_field :body %>
       </div>
       <div class="field">
         <%= f.label :chk %><br>
         <%= f.check_box :chk %>
       </div>
       <div class="actions">
         <%= f.submit %>
       </div>
       <div id="status"></div>
     <% end %>
    
    #app/controllers/memos_controller.rb
      def update
        if @memo.update(memo_params)
          @status = "O.K."
        else
          @status = "N.G."
        end
        render
      end
    
    #app/views/memos/update.js.erb
    $('#status').html("<%= j(@status) %>");
    

     scaffoldで生成された状態からformにremote: trueを追加してcontrollerをすこし弄ってupdate.js.erbを新規作成しただけですが、フォームのボタンをクリックするとAjax通信でデータの更新処理が完了し、コントローラから渡された文字列をビューに表示出来ます。フレームワークが用意してくれた仕組みを素直に使えば非常に簡単にAjax更新処理が実現できました。
     そこで今度はフォームのsubmitボタンではなく、チェックボックスをクリックすることで同じことをやろうとしてフォームのcheck_boxの行を<%= f.check_box(:chk, {:onchange => "this.form.submit();"}, "t", "f") %>と書き換えて、チェックボックスをクリックするとInvalidAuthenticityTokenエラーが出ました。

    InvalidAuthenticityTokenエラー画面

     javascriptでsubmitしてしまうとcsrf-tokenが送られないということでしょう。ユーザ認証(ログイン処理)していなくてもPOST(この場合PATCH)リクエストを送る場合は必要なのか?と思いながらフォーム内に <%= hidden_field_tag(:authenticity_token, form_authenticity_token) %>を記述して再度チェックボックスをクリックすると今度はTemplate is missingエラー。

    Template Missing エラー画面

     フォームのPATH指定時にformatを指定してこのように<%= form_for(@memo, url: memo_path(@memo, format: :js), remote: true) do |f| %>書き換えて、再度チェックボックスをクリックすると、ブラウザにupdate.js.erbファイルの中身が文字として表示されました。

    Text Response 画面

     ここまで来てもすぐには自分の間違いに気が付きませんでした。上でも自分で書いているけど、javascriptで普通にsubmitしているのだからAjax通信出来ていないようです。remote: true を書くことでフォームがAjax用になっているからform.submit()でAjax通信が出来るような錯覚をしていました。冒頭で思わぬエラーに遭遇したと書いたのはこのことです^^;
     HTMLのソースを見てみるとフォームにはdata-remote="true"の属性が付加されていたので、onchangeでthis.form.submit()するのではなくjavascriptの関数を作ってその中でform.setAttribute('data-remote', true);とやってcheckboxに無理やりdata-remote="true"の属性を付加してみましたが効果ありませんでした^^; Railsがどういう仕組みでAjaxを実現しているのかよくわかってませんが、Railsで予定していない使い方であることは確かなようです。
     で、form.submit()ではダメだとわかったので次のようにcheckboxのonchange=のイベントを削除して、jQueryでイベントを登録してそのイベント内でAjax通信するようにしました。Ajax送信後のCallback処理もそこに記述したのでjs.erbは使いません。render :text => @statusとすれば.doneイベント(コールバック関数)のdata引数に@statusの内容が入って来るので。$('#status').html(data);として画面に表示します。これで一応UnobtrusiveなJavascriptというかHTMLとスクリプトの分離が出来たのは目出度いことです。
     それと、先ほどエラー回避のためにフォーム内のPATHを変更しましたが、_form.html.erbはupdateのときだけでなくcreateのときも使用しているので元に戻しておきます。そうすればチェックボックスでの更新処理はあくまで追加の機能として作成できます。

    #app/views/memos/_form.html.erb
    <%= javascript_tag do %>
    jQuery(function ($){
        $("#memo_chk").on('change', function(){
            body = $("#memo_body").val();
            authenticity_token = $("#authenticity_token").val();
            $.ajax({
                url: '<%= memo_path(id: @memo.id, format: :js) %>',
                type: 'PATCH',
                dataType: 'html',
                data: {
                    id: '<%= @memo.id %>',
                    authenticity_token: authenticity_token,
                    memo: {
                        body: body,
                        chk: (this.checked) ? 't' : 'f'
                        }
                }
            }).done(function(data, status, xhr) {
                $('#status').html(data);
            }).fail(function(xhr, status, error) {
                alert('Error Occured(' + error + ')');
            });
         });
    });
    <% end %>
    
    <%= form_for(@memo, remote: true, html:{name: "frm_chkbox"}) do |f| %>
      <%= hidden_field_tag(:authenticity_token, form_authenticity_token) %>
      <div class="field">
        <%= f.label :body %><br>
        <%= f.text_field :body %>
      </div>
      <div class="field">
        <%= f.label :chk %><br>
        <%= f.check_box(:chk) %>
      </div>
      <div class="actions">
        <%= f.submit %>
      </div>
    
      <div id="status"></div>
    <% end %>
    
    #app/controllers/memos_controller.rb
      render :text => @status
    

     これで一応checkboxのクリック時は動くのですが、render部分を変更したために更新ボタンをクリックした場合に何も表示されません。はじめはcheckboxイベント用の別メソッドをコントローラに追加したのですが、更新処理自体は同じものなので既存のupdateメソッドをそのまま使って表示の際に分岐するようにします。
     それからテンプレートとjavascriptは分けておこうと思ってjavascript部分をapp/asset/javascript/ディレクトリに別ファイル(chkbox.js)として移動したのですが、ajax呼び出し時のurl:パラメータが上手く展開されませんでした。拡張子に「erb」を追加すればRailsがちゃんと展開してくれるとどこかに書かれていたので.jsからerb.jsに拡張子を変えてみましたが上手くいきませんでした。
     それとRails4から新規プロジェクトを作成すると勝手にコントローラ毎のjs.coffeeファイルが作られるので、どうせならそちらに移動しようと思いcoffeescriptに書き換えて1最終的には以下のようにしました。

    #app/assets/javascripts/memos.js.coffee
    jQuery ($) ->
        $("#memo_chk").on "change", ->
            id = $("#memo_id").val()
            body = $("#memo_body").val()
            chk = $(this).is(":checked") ? "t" : "f"
            authenticity_token = $("#authenticity_token").val()
            $.ajax(
                url: "/memos/" + id + ".js"
                type: "PATCH"
                dataType: "text"
                data:
                    id: id
                    authenticity_token: authenticity_token
                    memo:
                        body: body
                        chk: (if chk then "t" else "f")
            ).done((data, status, xhr) ->
                $("#status").html data
                return
            ).fail (xhr, status, error) ->
                alert "Error Occured(" + error + ")"
                return
    
            return
    
        return
    
    #app/views/memos/_form.html.erb
    <%= form_for(@memo, remote: true, html:{name: "frm_memo"}) do |f| %>↓
      <%= hidden_field_tag(:memo_id, @memo.id) %>↓
      <%= hidden_field_tag(:authenticity_token, form_authenticity_token) %>
      <div class="field">↓
        <%= f.label :body %><br>↓
        <%= f.text_field :body %↓
      </div>↓
      <div class="field">↓
        <%= f.label :chk %><br>↓
        <%= f.check_box(:chk) %>↓
      </div>↓
      <div class="actions">↓
        <%= f.submit %>↓
      </div>↓
      <div id="status"></div>↓
    <% end %>↓
    
    #app/controllers/memos_controller.rb
      def update
        if @memo.update(memo_params)
          @status = "O.K."
        else
          @status = "N.G."
        end
        if request.method == "PATCH"
          render :text => @status
        else
          render template: "memos/update.js.erb"
        end
      end
    
    • urlパラメータはRailsの_PATH表記から文字列に変更
    • memo.idをview内のhiddenフィールドに保存して、jQueryでその値を取得するように変更
    • Railsのフォームから呼ばれた場合とjQueryから呼ばれた場合でrenderするものを変更する

     これで一応checkboxでの更新&表示処理が完成しましたが、Railsで生成したフォームからupdateメソッドを呼び出す場合とjQueryからupdateメソッドを呼び出す場合で、画面出力処理が2種類(js.erbと.doneイベント)に分かれています。このサンプルの場合@status変数に格納した文字列を画面に表示するだけの処理ですが、それでも同じ処理をするコードが2ヶ所に存在しているのはよくないでしょう。Javascript側からAjax通信を開始(request.send、$.ajax)するときにコールバック関数としてjs.erbを指定することが出来たり(もしかして出来る?)、RailsのフォームからAjax通信するときにcoffeescript(Javascript、jQuery)のコールバック関数を指定出来たりしたら出力関数を統一出来そうですがそれはそれでややこしそうです。
     あと、request.methodで分岐していますが、これでいいのかどうか分かりませんしこういうコードは見かけたことが無いような気がします。
     ということで結局実際に使用するアプリではformから更新ボタンを無くしてjs.erbは使わないやり方に統一することにしました。こんなことになるなら最初からjs.erbは使わずにjQueryとCoffeeScriptで作ることに決めておけばよかったような気がします。
     それと実際に使用するサイトでやりたかったことはlazy_high_chartsを使ったグラフ表示なのでdataTypeはtextでなくjsonでやりとりしています。とてつもなく汚いソースですが興味のある方はそちらで確認出来ます。

    • 修正前(左)と修正後(右)

    修正前グラフ編集画面

    修正後グラフ編集画面

     注意点としてこのアプリの場合、編集画面と新規登録画面が別々なのでよかったのですが、説明に使用したサンプルプロジェクトでは編集画面と新規登録画面で同じフォームを利用しているので新規登録画面でcheckboxをクリックしたときはjQueryイベントが発動しないようにするかフォームを分ける必要があると思います。
     そもそもcheckboxクリックで更新処理というのが特殊なUIなのかもしれませんが…。


    1. javascriptをcoffeescriptに書き換えるのはこちらのサイトを利用しました 



    blog comments powered by Disqus