inuinu blog(開発用)

BOT @wagagun の開発ノウハウや、IT向け?の雑記ブログです。

【Ruby on Rails7】cocoonを導入(その1:導入編)

この記事では、Ruby on Rails7のgemパッケージcocoonについて、導入・カスタマイズ行います。
今回はcocoonのインストールから、実行確認までを解説いたします。

Rails7+cocoonでは、一見動作しているように見えても、そのままでは致命的な問題があります。
目次の最後の方を確認してみてください。

Ruby on Railsのバージョン7に、cocoonを導入してみました。 以下にまとめます。

導入時の環境について

導入時の環境は以下の通り。

cocoonとは?

cocoonとは、親子関係をもつモデルの子の行を追加したり削除したりといった、Javascriptで書くととかく厄介な処理の面倒を見てくれるRails用のgemです。

もし、これをvanillaなJavascriptのみで記述するとなると、templateタグにひな形を作成し、cloneを特定のidに追加し、nameタグやidタグの一意性が担保されるように修正し、付随するイベントリスナーを再発火し…といった、考えるだけで気絶しそうな作業が待っていることでしょう。

ちなみに、単にcocoonで検索すると、Wordpressの人気無料テーマの記事が多数ヒットします。
可能な限り「rails cocoon」で検索することをおすすめいたします。

cocoonの導入手順

Gemfileに追加し、installする

Gemfileに以下の行を追加します。

gem 'cocoon'

以下のコマンドを実行します。

bundle install

JQueryを導入する

cocoonを動作した際にJQueryを要求された場合、以下のコマンドを実行します。

bin/importmap pin jquery

config/importmap.rbを確認し、挿入されていない場合は、追記してください。

# config/importmap.rb
pin "jquery", to: "https://ga.jspm.io/npm:jquery@3.7.1/dist/jquery.js"

さらに、app/javascript/application.jsを確認し、挿入されていない場合は、追記してください。

// app/javascript/application.js
import jquery from "jquery"
window.$ = jquery

まずは実行してみる

modelおよびscaffoldの作成

親子のテーブルを作成します。
親はscaffold、子はmodelのみを作成します。

テーブル・カラム名は他のサイトでも見かける名前にしておきます。

Rails7では、デフォルトのerbでは、scaffoldの一覧表示がdiv仕様で生成されます。
ただし、仕様なのか不具合なのかわかりませんが、hamlを先にインストールしておくと、Rails6以前のtableベースの一覧が生成されるようです。
この方がわかりやすいので、hamlベースで説明します。

●親のテーブルを作成

bundle exec rails g scaffold Project name:string description:string

●子のテーブルを作成

bundle exec rails g model Task description:string done:boolean project:belongs_to

project:belongs_toをつけると、MySQLではid同様、bigintになるようです。

作成された、project.rbを追記します。

# app/models/project.rb
has_many :tasks, dependent: :destroy
accepts_nested_attributes_for :tasks, reject_if: :all_blank, allow_destroy: true

多くの例題に沿ってreject_if: :all_blankを列記していますが、こちらを指定すると、未入力行がバリデーションの前に消えてしまい、子が0行で登録されてしまう恐れがあるので、私はまず指定しないです。

task.rbも追記します。

# app/models/task.rb
belongs_to :project

以下のコマンドを実行することで、データベースにテーブルができると思います。

rake db:migrate

本来ならば、バリデーションやテストの記述も必要だと思いますが、割愛します。

ビューの作成

app/views/projects/_form.html.hamlを差し替えます。

= form_for @project do |f|
  .field
    = f.label :name
    = f.text_field :name
  .field
    = f.label :description
    = f.text_field :description
  %hr
  %h3 Tasks
  #tasks
    = f.fields_for :tasks do |task|
      = render 'task_fields', :f => task
    .links
      = link_to_add_association 'add task', f, :tasks, class: 'btn btn-success'
  %hr
    = f.submit 'Save', class: 'btn btn-primary'

行追加リンクは、子の一覧のあとに挿入します。

次にapp/views/projects/_task_fields.html.hamlを作成します。

.nested-fields
  .field
    = f.label :description 
    = f.text_field :description
  .field
    = f.check_box :done
    = f.label :done 
  = link_to_remove_association "remove task", f, class: 'btn btn-danger'
%hr

行削除リンクは、行単位に設定します。

コントローラーの変更

app/controllers/projects_controller.rbを変更します。

(省略)
  # GET /projects/new
  def new
    @project = Project.new
    # 子モデルも作成
    @project.tasks.build
  end
(中略)
  def create
    @project = Project.new(project_params)

    respond_to do |format|
      if @project.save
        format.html { redirect_to project_url(@project), notice: "Project was successfully created." }
        format.json { render :show, status: :created, location: @project }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @project.errors, status: :unprocessable_entity }
      end
    end
  end
(中略)
  def update
    respond_to do |format|
      if @project.update(project_params)
        format.html { redirect_to project_url(@project), notice: "Project was successfully updated." }
        format.json { render :show, status: :ok, location: @project }
      else
        format.html { render :edit, status: :unprocessable_entity }
        format.json { render json: @project.errors, status: :unprocessable_entity }
      end
    end
  end
(中略)
  private
  
  # Use callbacks to share common setup or constraints between actions.
  def set_project
    @project = Project.find(params[:id])
  end

  # Only allow a list of trusted parameters through.
  def project_params
    # 子供のパラメータも追加
    params.require(:project).permit(:name, :description, tasks_attributes: [:id, :description, :done, :_destroy])
  end

def new@project.tasks.buildにて、新規登録画面で、1行がデフォルト表示されます。
def createおよびdef updateのバリデーション失敗の場合も行がunprocessable_entityとなっていない場合は、差し替えてください。
さらに、private以降のストロングパラメータ定義で、tasks_attributes: [:id, :description, :done, :_destroy])のように子のパラメータも定義してください。

これにより、登録・変更・削除で自動的に子も追加・変更・削除されます。

多くのシステムがそうであるように、updateの場合は親は更新されますが、子は削除⇒追加となるはずです。

子のストロングパラメータ定義で、:idのない例題があるかもしれませんが、この場合、update時に削除されず、見た目、二重登録のような現象が発生します。

Javascriptの追記(任意)

「行追加時に任意の行から値をコピーしたい」「ある行数を超えたら行追加のボタンを消したい」という場合は、JQueryでゴリゴリ書いていくことになります。
一例を提示しておきます。

●app/javascript/application.js

// app/javascript/application.js
$(document).on('ready page:load turbolinks:load turbo:load', function() {
  // 共通変数の定義など

  // こちらにonload処理を記述

  // 各アクション毎に処理を挿入することができる
  $('#tasks').on('cocoon:before-insert', function() {
    console.log("cocoon:before-insert!");
  })
  .on('cocoon:after-insert', function() {
    console.log("cocoon:after-insert!");
  })
  .on('cocoon:before-remove', function() {
    console.log("cocoon:before-remove!");
  })
  .on('cocoon:after-remove', function() {
    console.log("cocoon:after-remove!");
  });
});

ブラウザのF12の操作ログをで動作確認を行ってください。

#tasksapp/views/projects/_form.html.hamlの10行目のidを指定します

ready page:load turbolinks:load turbo:loadは、発火するためのおまじないのように見えます。
turbo(以前はturbo-link)はJavascriptの動作が度々停止するようなので、このようになっているようです。

この絡みで致命的な問題が発生します(後述)。

実行

bundle exec rails s

にて、動作を確認しましょう。

致命的?な問題が発生

動作確認を行うと、バリデーションエラー時にJavascriptがうまく実行できない

動作確認では、主に以下の箇所をチェックするはずです。

  • 新規追加の場合に空行が1行表示されているか?
  • 追加ボタン、削除ボタンの表示
  • 同、動作確認
  • 新規追加の場合、明細行が正しく追加されるか?
  • 変更の場合
    • 行削除した行が登録されていないか?
    • 新たに追加した行が登録されているか?
    • 正しく変更されているか?
    • 子が二重登録されないか?
  • 削除の場合、親子両方が削除されているか?
  • reject_if: :all_blank
    • 記述を外した場合、子が0件の場合エラーとなるか?
  • バリデーションエラー(422:unprocessable_entity)でも、JQuery内に記述した処理が行われるか?

ほとんどの処理はうまく実行されると思いますが、アンダーラインを引いた「バリデーションエラー(422:unprocessable_entity)でも、JQuery内に記述した処理が行われるか?」が、なぜかうまく行かないことに気がつくはずです。

多分に、またTurboの仕業だな!と思ったりしているでしょうが、はい、そのとおりです。

これは回避可能か?

結論を書くと、回避可能です。

Turboを束ねるHotwireのある機能を使えば、ふた通りの解決方法がありそうです。

解決策は下記リンクをご参照ください。

inuinu-tech.hatenablog.jp