wataメモ

日々のメモをつらつらと書くだけ

Ruby on Railsでサービスを作ってみたpart3

 今回で3回目の「Ruby on Railsでサービスを作ってみた」記事投稿。part1part2はこちらからどうぞ。では今回も画面や機能の開発周りの続きを書いていく。詳細なのは書いてもしょうが無いかと思って掻い摘んでいると、逆にブログだけだとわからない感じになっている気がするが気にしない。もう少し理解したい人は是非ソースを読んでフィードバックを貰えると嬉しい。

アンケート編集

画面キャプチャ

f:id:wata_htn:20150930140945p:plain

アンケート情報

 上部にアンケート情報、と言ってもタイトルと非公開、画像ぐらいの設定しか無いが設定出来、「質問追加」ボタンを押すと質問入力フォームを動的に追加して行ける。質問フォームは「回答パターン」によって表示がかわり、単数または複数の場合は回答候補を動的に追加していくことが出来る。
 一応スマフォでも投稿出来るように、簡単なレスポンシブ画面になっている。右上の削除ボタンは画面サイズ的に厳しかったので、狭まると単なる「☓」ボタンに変化させた。

f:id:wata_htn:20150930174316p:plain

非公開アンケート

 非公開の場合はURLをユーザに通知するのと、非公開であることを少しでもわかりやすくするために見た目を変えている。これもGitHubのprivateリポジトリイメージ。

f:id:wata_htn:20150930174524p:plain

アンケート削除

 削除の場合は取り消せない操作なので、ユーザにアピールを強めに行うUIにしている。全画面Dimmerにして、確認事項を注視出来るようにした。

f:id:wata_htn:20150930174740p:plain

未実装機能

 結構アンケートは作るのが大変なので、テンプレート機能や、編集中にうっかり別画面へ遷移した時のalertを出すことや、現状の状態をlocalStorageに入れておいて、うっかりブラウザを閉じてもlocalStorageに残っている場合は復元させる等も良いだろう。今回は時間の都合上割愛。

実装について

 編集画面の実装を紹介するにはまず、どこがどのタイミングでレンダリングされているかを示す必要がある。アンケートの質問は複数登録可能で、質問に対する回答も複数登録することになる。すべてをサーバサイドでレンダリングしてしまうと、質問を追加する度に画面をリフレッシュしたりしなければならないので、質問以降はクライアントサイド(JavaScript側)でレンダリングを行いたい。

f:id:wata_htn:20150930141224p:plain

 こうすることで、画面のリフレッシュは防げるが、初回表示の時に質問をサーバサイドでレンダリングしてしまうと、今度はテンプレートの共有化問題が発生してしまう。サーバ側のテンプレート(今回で言えばslim)とクライアント側のテンプレート(今回で言えばHandlebars)で同じ質問をレンダリングする為のテンプレートが別れてしまうということだ。JavaScript側でも昨今はサーバサイドレンダリングの技術があるのでそれを使っても良いし、サーバから返すデータをJSONではなくhtmlにして単純にappedすることも出来る。リクエストが毎回飛んでしまうのもアレなので今回は素直にクライアントサイドにすべて寄せることにした。
 アンケート情報は通常のslimでレンダリングを行い、質問の情報はgonを使ってhtml上にJSONレンダリングしている。特に今回はwatch機能は使っていないので無理にgonを使う必要な無かった。

app/views/survey/edit.html.slim

  | $(function() {
      var edit = new SurveyEdit();
      edit.init();
      $(gon.questions).each(function(i, v) { // ここでgonというグローバル変数に入っているquestionsを使ってクライアントサイドでレンダリングを行っている
        edit.add_question(v); // 下で紹介しているadd_questionメソッドに渡している
      });
    });

app/controllers/survey_controller.rb

  def set_questions
    # ここでgonに必要なデータを代入している
    # 色々コントロールしたかったのでas_jsonした物を代入
    gon.questions = @survey.questions.as_json({
      methods: [
        :error_class, :text_error_class, :value_error_class, :value_data,
        :resize_image_url
      ],
    })
  end

 今回はhandlebars_assetsを利用しているので自分でcompileしなくてもいいし、テンプレートも最初から別ファイルで管理出来るようになっている。(前はscriptタグでテンプレートを埋め込んでいた)その場合にJavaScript側から参照するには「HandlebarsTemplates」というグローバル変数から参照するのだが、長いので公式ドキュメントのように「JST」に変更している。

app/assets/javascripts/survey_edit.coffee

  add_question: (v) =>
    $('#add-q-pos').append(JST['survey/question'] v) # ここでHandlebarsテンプレートにデータを渡してレンダリングしている
    $('select').dropdown()
    $('select[name^="surveys[questions_attributes]"]', '#q-' + v.id).trigger('change', [true])

 このテンプレートの別ファイル管理および自動コンパイルは非常に便利だと感じた。このブログを書いている時点では「.slimbars」対応はしていないので通常のhtmlで記述しているがテンプレートがslimで書けるのも魅力的だ。

ディレクトリ構成イメージ

app/assets/javascripts
|--answer.coffee
|--app.coffee
|--application.js
|--question.coffee
|--survey.coffee
|--survey_edit.coffee
|--templates # ここにテンプレートを置く
   |--survey
      |--_multiple.hbs # 「_」から始まるファイルはPartialsとなる
      |--_single.hbs
      |--question.hbs
      |--single.hbs

 画像周りで前回small_lightを使っているという事を書いたが、前回はサーバサイドのレンダリングの時だったので綺麗に実装出来たが、クライアントサイドのレンダリングとなると話が違ってくる。今回は質問の画像がそれにあたるわけだが、画像のサイズはコントローラとかで指定するのではなく、テンプレート側で指定したい。しかしクライアントサイドに来た時には既にJSONになっており、後からURLを変更するのがちょっと面倒なことになってしまう。  そこでJSONとして出力するときにはプレースホルダーを埋め込んだ状態で出力し、Handlebarsでレンダリングする時にHelpersで独自に関数を用意して置換する方法を取った。JSONかでは先程紹介したas_jsonのところで呼び出すメソッド(resize_image_url)を追加している。

app/models/question.rb

  def resize_image_url
    return '' unless self.image.url
    path = self.image.url.gsub Survey::Application.config.image_asset_host, ''
    File.join Survey::Application.config.image_asset_host, "/resize/w/:dw/h/:dh/", path # ここで「:dw」と「:dh」というプレースホルダーの状態を作る
  end

app/assets/javascripts/survey_edit.coffee

Handlebars.registerHelper 'resize_image_url', (url, opt) ->
  return '' if url == null
  url.replace(':dw', opt.hash.dw).replace(':dh', opt.hash.dh) // ここでプレースホルダーを置換している

 Handlebarsのテンプレート上の記述は以下のようになる。上のソースでregisterHelperしたresize_image_urlの第2引数のoptに渡すために「dw="400" dh="400"」という記述を追加している。

app/assets/javascripts/templates/survey/question.hbs

<img src="{{#resize_image_url resize_image_url dw="400" dh="400"}}{{/resize_image_url}}" class="ui image fluid">

 これでテンプレート側で画像サイズの指定が出来るようになった。
 次は画像の削除だが、新しい画像と差し替えたい場合は気にせず新規の画像をアップロードして貰えばいいが、削除だけしたい場合はCarrierWaveの仕様に乗っ取る必要がある。CarrierWaveは画像を削除させたい場合は「remove_#{column_name}」というパラメータを送ることでよしなに対応してくれる。この「column_name」はモデルのカラムでCarrierWaveがmountするカラムとなる。CarrierWaveの設定周りはpart1のCarrierWaveの設定を参照して欲しい。前回は紹介しなかったが、ファイル名を保存するカラムに設定で生成したUploaderをmountする必要がある。

app/models/surveys.rb

mount_uploader :image, SurveyUploader

 後はhtml側でhidden値を追加し、削除ボタンが押されたらhidden値を書き換えるようにしている。

f:id:wata_htn:20150930170711p:plain

app/views/survey/edit.html.slim

.blurring.dimmable.image.survey-image
  .ui.dimmer
    .content
      .center
        / 削除用dimer+button
        .ui.inverted.button.del-i
          | 削除
  .image
    = image_tag_resize @survey.image, dw: 400, dh: 400, class: 'ui fluid image'
/ hidden値をデフォルトfalseで入れておく
input type="hidden" name="surveys[remove_image]" value="false"

app/assets/javascripts/survey_edit.coffee

# hoverでdimmer+ボタンを表示させるように設定
$('.survey-image').dimmer {on: 'hover'}

# 削除ボタンを押されたらアニメーションしてhidden値書き換え
$(document).on 'click', '.del-i', ->
  $(@).parents('.survey-image').transition('jiggle').transition('scale')
    .parent().find('input[name="surveys[remove_image]"]').val true

 今度はレコードの更新処理について。親子関係(今回で言うとアンケートと質問の関係)になっていて、子供が複数の場合、追加する分にはパラメータを渡せばいいし、更新の場合はidを渡せばハンドリングは出来る。ただ、削除の場合は普通にやってしまうとパラメータが渡ってこないので通常だと面倒な処理だがRailsactiverecord)なら簡単に出来る。

 まずは親のモデルに子供とまとめての更新、削除を許すように設定する。

app/models/surveys.rb

accepts_nested_attributes_for :questions, :allow_destroy => true

 こうしておけばパラメータ名として「surveys[questions_attributes][][text]」みたいに渡せば後はよしなにやってくれる。わかりづらいのはリレーション名は「questions」としているがその場合の一括更新パラメータ名は「questions_attributes」となることだ。決まった数のレコードまでしか登録出来ないUIならまだしも、動的に上限なしの場合はどうしても動的にフォームを作成する必要があり、その場合はJavaScript側のテンプレートでパラメータ名を指定しなければならない。この時にはこのルールを知らないと途方にくれてしまう。
 削除の場合はパラメータを渡さないのではなく「_destroy」パラメータを渡すことで削除が行われる。その為、既にDBに存在していた質問の削除のボタンを押すと、画面上から消すがhiddenでパラメータは渡すようにしている。もちろん、今回の登録で質問を追加して、DB登録する前に削除する場合はDOM毎削除している。途中の削除やらを気にしたくないので、インデックスを指定していない都合上、パラメータのインデックスズレを起こさないように基本的にパラメータは削除だろうが更新だろうが、ワンセット送った方が良いだろう。

app/assets/javascripts/survey_edit.coffee

$(document).on 'click', '.del-q', ->
  id = $(@).data 'id'
  if id # DBに存在している場合は「_destroy」hidden値を書き換えてfadeOutさせる
    $("#q-#{id}").find('input[name="surveys[questions_attributes][][_destroy]"]').val true
    $("#q-#{id}").fadeOut 200
  else # まだ登録されていない場合はDOM毎削除
    $(@).parents('.question').fadeOut 200, ->
      $(@).remove()

 ここまでお膳立てが整えばコントローラ側は非常にシンプルに出来る。

app/controllers/survey_controller.rb

@survey.update! survey_params

 survey_paramsはStrong Parametersの結果を返すメソッドだ。子供のパラメータの指定も一挙にやっている。

app/controllers/survey_controller.rb

def survey_params
  params.require(:surveys).permit(:name, :is_result_secret, :is_secret, :image, :remove_image,
    questions_attributes: [
      :id, :text, :type, :value, :no, :_destroy, :image, :image_cache, :remove_image
    ])
end

 後は親のリレーションでdependentとかautosaveとかを設定しておく。

app/models/surveys.rb

has_many :questions, -> { order(:no) }, :dependent => :destroy, :autosave => true

次回について

 今回も長くなってきたのでこの辺で。次回は画面の機能、実装周りの最終回としたい。