大ちゃんの駆け出し技術ブログ

RUNTEQ受講生のかわいいといわれるアウトプットブログ

【無職に転生 ~ 就職するまで毎日ブログ出す_25】【Rails】【タグ機能】Materialize のChipsで値をコントローラーに送る方法

はじめに

こんにちは、大ちゃんの駆け出し技術ブログです。

タイトルにあるとおり【無職に転生 ~ 就職するまで毎日ブログ出す】というチャレンジをしています!!!!昨日までは就活するまで本気出すでしたが、これだとまるで就活後は頑張らないのかと思われてしまいそうで、、、大人気アニメのタイトルのまるパクリチャレンジです。

https://i.gyazo.com/3a02b7aae4e5e538130d4bb199b55dc7.png

RailsやらRubyやらSQLやらその他Webの知識やらが色々と抜け落ちているのを感じており、知識の定着のためにもアウトプットする機会を増やすためです。加えて、退職して文字通り無職に転生しましてプロニートになり、毎日時間に余裕ができたので引き締めるためにも毎日投稿を思い至りました!

【投稿内容】

  • SQLの難しい処理 (副問合せ、JOINとか複雑な処理が書けない)
  • Rails全般 (純粋に必要な知識が多すぎる、網羅的な理解が足りない)
  • Rubyのあまり使わないメソッドや記述方法 (あまり重要ではないけど特に)
  • Web知識全般 (クッキーやら、セッションやらなんとなくで理解しているものの自分の言葉で説明できない)
  • 書籍 (スタートアップ企業に勤めるので、自分が会社に与える影響やパフォーマンスを高めるためビジネス書を読んでいきます。)

本日やること

チーム開発でタグ機能を実装する機会があったので、Gemなしでタグ機能を実装していきたいと思います。プロジェクトはプライベートリポジトリであるため、全てのコードをお見せすることはできませんが、基本的には流れなどがわかれば問題ないのかなと思っています。タグ機能を作成する際に少し手間取ったのが、Materialize CSSを使用した値の送信方法です。今回はそれがメインなのでタグ機能はほぼサブと考えてください。

タグ機能のER図

今回作成するタグ機能ではQiitaなどで使われるタグの機能です。なのでタグをつけるオブジェクトはPost(投稿)とします。

Postは複数のタグを持っていますね。Qiitaでも一つの投稿に複数のタグをつけることができます。そしてタグも複数の投稿に属しています。Qiitaでも複数の投稿が"Ruby"というタグをつけることができます。よってタグ機能の実装はいいね機能やフォロー機能と同じように多対多のアソシエーションの関係になります。

Postモデルにはtitleカラムとbodyカラムが元々あります。そこに新規モデルでTagモデル、中間テーブルであるTaggingモデルを作成します。

https://i.gyazo.com/81d623d0754209a4bf9b26c131a8324b.png

モデルの作成

タグ機能のためのモデルを作成します。まずは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」のタグが作成されます。

https://i.gyazo.com/327d368f40a3aa8b8d21d97622c7a680.png

コントローラーを編集

こちらも既に実装済みである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の方がわかりやすいですよね。

https://i.gyazo.com/bd2514fd38973598590bde0601d69382.png

Materialize CSS Chips

以下のような可愛らしいタグをデザインしてくれるMaterialize CSSの機能です。

https://i.gyazo.com/bd2514fd38973598590bde0601d69382.png

Chips

これでタグを入力できたらかなり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として表示されるはずなのですが、、、

https://i.gyazo.com/0f22b2a4e98ae55945df15b1a874df0e.png

これは下記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>

これでエンターで入力できるようになりました。しかも、同じ値は入力できないようにしてくれています。

https://i.gyazo.com/5623684e684a1d7821be8e3f2cf472b1.gif

chipsの値をコントローラーに送る

これでフォームに送信できるようになったのかというとそうではありません。実際に試してみるとわかるのですが、値が送られていないのです。

理由としてはchipsの仕様にあります。実は作成されたchipsはフォームのバリューになっているのではなく、divタグの中にchipクラスとして追加されているのです。下記のgifを見ると分かるのですが、chipクラスを持つdivタグがどんどん作成されています。しかし、これらの値はinputフォームのバリューとなっていないため、入力した値を送ることができていないのです。

https://i.gyazo.com/ff3fce63ec3ee8200e0be7b32a0d37ed.gif

よってこの作成された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がプレイスホールダーとして表示されます。

https://i.gyazo.com/dc9db5df75e74771591206a1c555d153.gif

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>

よりいい方法がないか模索していきたいなと思いますのでアップデートがあればまた記事にします。