【無職に転生 ~ 就職するまで毎日ブログ出す_25】【Rails】【タグ機能】Materialize のChipsで値をコントローラーに送る方法
はじめに
こんにちは、大ちゃんの駆け出し技術ブログです。
タイトルにあるとおり【無職に転生 ~ 就職するまで毎日ブログ出す】というチャレンジをしています!!!!昨日までは就活するまで本気出すでしたが、これだとまるで就活後は頑張らないのかと思われてしまいそうで、、、大人気アニメのタイトルのまるパクリチャレンジです。
RailsやらRubyやらSQLやらその他Webの知識やらが色々と抜け落ちているのを感じており、知識の定着のためにもアウトプットする機会を増やすためです。加えて、退職して文字通り無職に転生しましてプロニートになり、毎日時間に余裕ができたので引き締めるためにも毎日投稿を思い至りました!
【投稿内容】
- SQLの難しい処理 (副問合せ、JOINとか複雑な処理が書けない)
- Rails全般 (純粋に必要な知識が多すぎる、網羅的な理解が足りない)
- Rubyのあまり使わないメソッドや記述方法 (あまり重要ではないけど特に)
- Web知識全般 (クッキーやら、セッションやらなんとなくで理解しているものの自分の言葉で説明できない)
- 書籍 (スタートアップ企業に勤めるので、自分が会社に与える影響やパフォーマンスを高めるためビジネス書を読んでいきます。)
本日やること
チーム開発でタグ機能を実装する機会があったので、Gemなしでタグ機能を実装していきたいと思います。プロジェクトはプライベートリポジトリであるため、全てのコードをお見せすることはできませんが、基本的には流れなどがわかれば問題ないのかなと思っています。タグ機能を作成する際に少し手間取ったのが、Materialize CSSを使用した値の送信方法です。今回はそれがメインなのでタグ機能はほぼサブと考えてください。
タグ機能のER図
今回作成するタグ機能ではQiitaなどで使われるタグの機能です。なのでタグをつけるオブジェクトはPost(投稿)とします。
Postは複数のタグを持っていますね。Qiitaでも一つの投稿に複数のタグをつけることができます。そしてタグも複数の投稿に属しています。Qiitaでも複数の投稿が"Ruby"というタグをつけることができます。よってタグ機能の実装はいいね機能やフォロー機能と同じように多対多のアソシエーションの関係になります。
Postモデルにはtitleカラムとbodyカラムが元々あります。そこに新規モデルでTagモデル、中間テーブルであるTaggingモデルを作成します。
モデルの作成
タグ機能のためのモデルを作成します。まずはTagモデルです。
$ rails g model tag name:string Running via Spring preloader in process 79374 invoke active_record create db/migrate/20211024010225_create_tags.rb create app/models/tag.rb
nameカラムは空値を入れることができないように制御します。さらにタグの名前は重複しないようにadd_indexでnameカラムに一意性制約をかけます。
# db/migrate/xxxxxxxxxxxx_create_tags.rb class CreateTags < ActiveRecord::Migration[6.1] def change create_table :tags do |t| t.string :name, null: false t.timestamps end add_index :tags, :name, unique: true end end
続いて中間テーブルの役割であるTaggingモデルも作成しましょう。
$ rails g model tagging post:references tag:references
同じ投稿に対して同一のタグが付かないように、tag_idとpost_idの組み合わせは一意であるように制約をかけます。
# db/migrate/xxxxxxxxxxxx_create_taggings.rb class CreateTaggings < ActiveRecord::Migration[6.1] def change create_table :taggings do |t| t.references :post, null: false, foreign_key: true t.references :tag, null: false, foreign_key: true t.timestamps end add_index :taggings, %i(tag_id post_id), unique: true end end
よく見直して問題がなけれマイグレーションします。
$ rails db:migrate
続いて各モデルファイルにアソシエーションとバリデーションを記載します。
# app/models/post.rb class Post < ApplicationRecord has_many :taggings, dependent: :destroy has_many :post_tags, through: :taggings, source: :tag end
# app/models/tag.rb class Tag < ApplicationRecord has_many :taggings, dependent: :destroy has_many :tagged_posts, through: :taggings, source: :post validates :name, presence: true, uniqueness: true end
# app/models/tagging.rb class Tagging < ApplicationRecord belongs_to :post belongs_to :tag validates :tag_id, uniqueness: { scope: :post_id } end
フォームを編集
タグ作成機能はTagControllerを経由してタグが作成されるのではなく、Post作成時にタグも同時に作成されるので、Post作成機能のフォームに作成するタグを記載します。
※ 現在既にPost作成機能のためのフォームがあるとします。
<div class="field"> <%= form.label :tag_names, class: 'form-label' %> <%= form.text_field :tag_names, class: 'form-control', placeholder: '空白スペースで区切って入力してください' %> </div>
tag_namesはpostのカラムではありません。form.labelを機能させるためにi18nファイルに対してtag_namesに対応する日本語を指定しましょう。
# config/locales/activerecord/ja.yml post: title: タイトル body: 本文 tag_names: タグ
入力形式としては空白区切りでタグが入力されるようにします。以下のように入力した場合、「タグサンプル1」と「タグサンプル2」のタグが作成されます。
コントローラーを編集
こちらも既に実装済みであるPost作成機能を上書きする形で実装します。下記のようなシンプルな形式です。
class PostController < ApplicationController def create @post = Post.new(post_params) if @post.save redirect_to @post, notice: t('.success') else flash.now[:alert] = t('.fail') render :new end end private def post_params params.require(:post).permit(:name, :body) end end
上記の処理にタグを作成する処理を加えます。まず、タグの値がパラメーターとして渡ってきています。
params[:post][:tag_names] => "タグサンプル1 タグサンプル2"
上記の値では空白で値が連結されているため、空白区切りで値を分割した配列に変更します。splitメソッドを使えば実装できます。
params[:post][:tag_names].split(' ') => ["タグサンプル1", "タグサンプル2"]
さらにタグの値の重複を防ぐ必要があるのでuniqメソッドも使います。
params[:post][:tag_names].split(' ') => ["タグサンプル", "タグサンプル"] params[:post][:tag_names].split(' ').uniq => ["タグサンプル"]
これを一つのprivateメソッドにまとめておきます。
def tag_names params[:post][:tag_names].split(' ').uniq end
上記の値を使用してタグを作成する処理を書きますが、コントローラーにロジックを長く書くのは好ましくないため、postモデルにロジックを移すようにします。save_withメソッドと記載しておいてロジックはモデルで記載します。
class PostController < ApplicationController def create @post = Post.new(post_params) if @post.save_with(tag_names) redirect_to @post, notice: t('.success') else flash.now[:alert] = t('.fail') render :new end end private def post_params params.require(:post).permit(:name, :body) end def tag_names params[:post][:tag_names].split(' ').uniq end end
モデルでsave_withメソッドのロジックを書きます。先に処理は書いておきます。
def save_with(tag_names) ActiveRecord::Base.transaction do self.post_tags = tag_names.map { |name| Tag.find_or_initialize_by(name: name.strip) } save! end true rescue StandardError false end
ActiveRecord::Base.transaction
は複数のSQL処理を一つのトランザクションにまとめる時に使用します。これを使う理由としてはタグとPostが同時に作成される処理のため、複数のSQLが走ると予想されるからです。
Tag.find_or_initialize_by(name: name.strip)
の処理ですが、find_or_initialize_by
は作成するタグの値が既に存在していればそのタグを返し、存在していなければインスタンスかするメソッドです。それによってタグの名前が重複することを防ぎます。
最後にsave!
メソッドで保存します。ここでもしバリデーションエラーとなれば、トランザクションがロールバックし作成されたタグなども作成されなかったことになります。そして例外処理でfalse
を返すようにしています。
これで無事にタグ作成機能ができました!
入力UIの向上
今回タグ作成機能を作るにあたりフォームを空白区切りとしましたが、フォームを入力する時が非常に簡素です。本来タグ入力は下記のようなUIの方がわかりやすいですよね。
Materialize CSS Chips
以下のような可愛らしいタグをデザインしてくれるMaterialize CSSの機能です。
これでタグを入力できたらかなりUIが向上すると思ったので今回導入してみました。
フォーム編集
それでは導入してみます。
Materialize公式を参照すると下記のようにすれば実装できるそうです。chipsクラスの中にinputフォームがあればいいそうです。
<div class="chips"> <input class="custom-class"> </div>
$('.chips').chips();
これに倣って前回作成したタグのフォームを修正します。text_fieldはinputタグに変換されるのでそのままにして、それを囲うdivタグにchipsクラスを付けます。
<div class="chips"> <%= form.label :tag_names, class: 'form-label' %> <%= form.text_field :tag_names, class: 'form-control', placeholder: '空白スペースで区切って入力してください' %> </div>
$('.chips').chips();
ただこれだと入力ができませんでした。正確には入力はできるのですが、エンターを押してもchipsとして表示されません。入力してエンターを押せばchipsとして表示されるはずなのですが、、、
これは下記issueに上がっていました。
When initializing a div with the
chips
class, if there is a label in that div, the init function raises a TypeError.
Chips init function throws TypeError when there is a label · Issue #6124 · Dogfalo/materialize
どうやらchipsの中にlabelがあるとエラーとなるそうです。なので不格好ではありますが下記のようにlabelをdivタグの外に出してしまいます。
<%= form.label :tag_names, class: 'form-label' %> <div class="chips"> <%= form.text_field :tag_names, class: 'form-control', placeholder: '空白スペースで区切って入力してください' %> </div>
これでエンターで入力できるようになりました。しかも、同じ値は入力できないようにしてくれています。
chipsの値をコントローラーに送る
これでフォームに送信できるようになったのかというとそうではありません。実際に試してみるとわかるのですが、値が送られていないのです。
理由としてはchipsの仕様にあります。実は作成されたchipsはフォームのバリューになっているのではなく、divタグの中にchipクラスとして追加されているのです。下記のgifを見ると分かるのですが、chipクラスを持つdivタグがどんどん作成されています。しかし、これらの値はinputフォームのバリューとなっていないため、入力した値を送ることができていないのです。
よってこの作成されたchipsを値として取得しコントローラー側にパラメータを送る必要があります。そのためにはJavaScriptでchipクラスの値を取得してそれを送信する際にフォームのバリューに設定することで解決できます。
まずフォームを整理します。結論から言うとタグの部分は下記のように実装します。
<div class="field"> <%= form.label :tag_names %> <div class="chips"><input></div> <%= form.hidden_field :tag_names, id: 'tag-hidden-field' %> </div>
タグを入力する部分にtext_fieldではなく直接inputを使用する理由は、後ほどJavaScriptでフォームに値を入れる時にその挙動がユーザー側に見えてしまうのを防ぐためです。今回実装する挙動として「作成ボタンをクリック」 ⇒ 「JSでフォームにバリューを入れる」なのですが、もしtext_fieldの中に入れてしまうと送信ボタンをクリックした時に一瞬chipクラスの値が見えてしまうのでとても不自然なUIになります。よって、text_fieldに入れるのではなく、hidden_fieldにバリューを格納することで上記のような不自然な挙動を制御します。
次にJavaScriptのコードを実装します。今回自分はjQueryを使っていたのでjQueryのコードになりますがJSでも問題なく実装できると思います。
$(".chips").chips({ placeholder: "Enterで入力", secondaryPlaceholder: "+Tag", data: getChipsData($("#tag-hidden-field").val()), }); // chipsの初期データを取得するメソッド function getChipsData(values) { return !values ? [] : values.split(",").map(function (value) { return { tag: value }; }); } // 作成時にchipsの値をフォームに格納 $("#idea-btn").on("click", function () { const tags = M.Chips.getInstance($(".chips")).chipsData.map(function ( value ) { return value["tag"]; }); $("#tag-hidden-field").val(tags); });
まず初期化コードを確認します。
$(".chips").chips({ placeholder: "Enterで入力", secondaryPlaceholder: "+Tag", data: getChipsData($("#tag-hidden-field").val()), });
secondaryPlaceholderとはchipを一度作成した後にフォームに表示されるプレイスホールダーです。下のgifを見るとわかるように、最初はplaceholderが表示されていますが、一つchipを作成するとsecondaryPlaceholderがプレイスホールダーとして表示されます。
dataプロパティは初期状態で作成されているchipクラスのdivタグを設定できます。ここではgetChipsDataメソッドを独自で定義しています。これは今回の実装ではあまり意味がないのですが、更新機能を作成する際に既にタグが作成されている時に初期状態でそれらのタグを表示することができるので便利です。
// chipsの初期データを取得するメソッド function getChipsData(values) { return !values ? [] : values.split(",").map(function (value) { return { tag: value }; }); }
ボタンが作成された時の挙動は先ほど説明した通りです。M.Chips.getInstance($(".chips")).chipsDataでchipsのなかのchipの配列を取得することができます。それに対してmapメソッドでvalue["tag"]
から値を取り出してその配列をフォームのhidden_fieldに送っています。
// 作成時にchipsの値をフォームに格納 $("#idea-btn").on("click", function () { const tags = M.Chips.getInstance($(".chips")).chipsData.map(function ( value ) { return value["tag"]; }); $("#tag-hidden-field").val(tags); });
終わりに
これによりMaterialize CSSを使用してタグの作成ができるようになります。正直hidden_fieldにvalueを渡す方法はベストプラクティスとは思っていません。かなりゴリ押しだと思っています。
<div class="field"> <%= form.label :tag_names %> <div class="chips"><input></div> <%= form.hidden_field :tag_names, id: 'tag-hidden-field' %> </div>
よりいい方法がないか模索していきたいなと思いますのでアップデートがあればまた記事にします。