This page looks best with JavaScript enabled

【Rails】fields_forで外部アカウント登録フォーム

 ·   ·  ☕ 5 分で読めます
✏️

環境

  • Rails 6.1.3.2
  • Tailwind 2.2.4

実装するもの

image

こんな感じのフォームです。
アイデアはQiitaから拝借しました。
image

アソシエーションと各カラムの値

アソシエーション

User -< SocialAccount >- SocialService

各カラム

# Table name: social_accounts
#
#  id                :bigint           not null, primary key
#  identifier        :text(65535)      not null
#  created_at        :datetime         not null
#  updated_at        :datetime         not null
#  social_service_id :bigint           not null
#  user_id           :bigint           not null
# Table name: social_services
#
#  id                :bigint           not null, primary key
#  position          :integer          not null
#  name              :string           not null
#  base_url          :text(65535)      not null
#  created_at        :datetime         not null
#  updated_at        :datetime         not null

SocialServiceの値

SocialServiceは、GitHubTwitterブログになります。事前に管理側で値を入れておくものなので、seedなどを利用する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
[1] pry(main)> SocialService.all
=> [#<SocialService:0x00007fd040925320
  id: 1,
  position: 1000,
  name: "その他リンク",
  base_url: "",
  created_at: Wed, 23 Jun 2021 02:10:43.476867000 JST +09:00,
  updated_at: Wed, 23 Jun 2021 02:11:18.593401000 JST +09:00>,
 #<SocialService:0x00007fd0408d7530
  id: 2,
  position: 999,
  name: "ブログ",
  base_url: "",
  created_at: Wed, 23 Jun 2021 02:10:43.479999000 JST +09:00,
  updated_at: Wed, 23 Jun 2021 02:11:18.995271000 JST +09:00>,
 #<SocialService:0x00007fd0408d6d38
  id: 3,
  position: 1,
  name: "GitHub",
  base_url: "https://github.com/",
  created_at: Wed, 23 Jun 2021 02:10:43.483004000 JST +09:00,
  updated_at: Wed, 23 Jun 2021 02:11:19.431160000 JST +09:00>,
 #<SocialService:0x00007fd0408d69f0
  id: 4,
  position: 2,
  name: "Twitter",
  base_url: "https://twitter.com/",
  created_at: Wed, 23 Jun 2021 02:10:43.486446000 JST +09:00,
  updated_at: Wed, 23 Jun 2021 02:11:20.160150000 JST +09:00>]

そして、それぞれのアカウントのURLはbase_urlと各サービスのIDを合わせたものなので、以下のメソッドを定義する。
base_urlがないアカウントも、identifierにURLを入れるようにする。

1
2
3
4
5
6
7
8
class SocialAccount < ApplicationRecord
  belongs_to :user
  belongs_to :social_service

  def url
    "#{social_service.base_url}#{identifier}"
  end
end

アカウントの編集ページ

ルーティング

設定のページをまとめるためにnamespaceを切っている。
ここはある程度自由でいいので参考までに。

1
2
3
4
5
Rails.application.routes.draw do
  namespace :settings do
    resources :social_accounts, only: %i[index]
  end
end

コントローラ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
module Settings
  class SocialAccountsController < ApplicationController
    def index
      social_accounts_hash = current_user.social_accounts.index_by(&:social_service_id)
      @social_accounts = all_social_accounts(current_user, social_accounts_hash)
    end

    private

    def all_social_accounts(user, social_accounts_hash)
      SocialService.all.map do |social_service|
        # social_accountレコードがあればそれ、なければnewする
        social_accounts_hash[social_service.id] || user.social_accounts.new(social_service_id: social_service.id)
      end
    end
  end
end

find_or_initialize_byを使えば以下のようにシンプルに書けるが、find_or_initialize_byのたびにクエリを発行するのでパフォーマンスが悪くなる。

そのため、current_user.social_accounts.index_by(&:social_service_id)で一度にレコードを取得して、そのハッシュを使って配列を作成している。
これは、【Rails】index_byとgroup_byを用いて取り回しのきくハッシュを作成するに詳しく書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
module Settings
  class SocialAccountsController < ApplicationController
    def index
      @social_accounts = all_social_accounts(current_user)
    end

    private

    def all_social_accounts(user)
      SocialService.asc.map do |social_service|
        SocialAccount.find_or_initialize_by(social_service_id: social_service.id, user_id: user.id)
      end
    end
  end
end

@social_accountsの中身は以下のような、実際のレコードのインスタンスとinitializeされたインスタンスが混ざった配列になる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
[1] pry(#<Settings::SocialAccountsController>)> @social_accounts
=> [#<SocialAccount:0x00007fa00abe2330
  id: nil,
  social_service_id: 1,
  user_id: 22,
  identifier: nil,
  created_at: nil,
  updated_at: nil>,
 #<SocialAccount:0x00007fa00b53ca70
  id: 1,
  social_service_id: 2,
  user_id: 22,
  identifier: "https://blog.aiandrox.com",
  created_at: Thu, 24 Jun 2021 08:46:55.906186000 JST +09:00,
  updated_at: Thu, 24 Jun 2021 08:46:55.906186000 JST +09:00>,
 #<SocialAccount:0x00007fa00b52d5c0
  id: 3,
  social_service_id: 3,
  user_id: 22,
  identifier: "aiandrox",
  created_at: Thu, 24 Jun 2021 09:31:05.522428000 JST +09:00,
  updated_at: Thu, 24 Jun 2021 09:43:30.552457000 JST +09:00>,
 #<SocialAccount:0x00007fa00abe1d90
  id: nil,
  social_service_id: 4,
  user_id: 22,
  identifier: nil,
  created_at: nil,
  updated_at: nil>]

ビュー

フォームは、クラスを除いてシンプルにするとこういう構造になる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
= form_with model: current_user, url: settings_social_accounts_path, method: :put do |f|
  - @social_accounts.each do |social_account|
    = f.fields_for 'social_accounts_attributes[]', SocialAccount.new do |ff|
      = ff.hidden_field :social_service_id, value: social_account.social_service.id
      = ff.label :service_name, "#{social_account.social_service.name} URL"
      br
      - if social_account.social_service.base_url.present?
        = social_account.social_service.base_url
      = ff.text_field :identifier, value: social_account.identifier
      br
  = f.submit

image

① f.fields_for ‘social_accounts_attributes[]’, SocialAccount.new do |ff|

第一引数は、【Rails】ネストされたattributes_paramsをフォームからコントローラに渡すと同じような感じ。

第二引数はsocial_accountではなく、SocialAccount.newにしないといけない。
fields_forでは、新規レコードのときは

1
2
3
4
'social_accounts_attributes' => [{
  'social_service_id' => '2',
  'identifier' => 'https://blog.aiandrox.com'
}]

こうなるが、既存レコードのときは、IDがキーとして振られるようになる。

1
2
3
4
5
6
'social_accounts_attributes' => [{
  '1' => {
    'social_service_id' => '2',
    'identifier' => 'https://blog.aiandrox.com'
  },
}]

そのため、どちらの場合も同じ構造になるように、SocialAccount.newにする。

参考:Action View フォームヘルパー - Railsガイド

これにTailwindのクラスを当てると、以下のようになる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
= form_with model: current_user, url: settings_social_accounts_path, method: :put, class: 'w-full max-w-lg text-sm' do |f|
  - @social_accounts.each do |social_account|
    = f.fields_for 'social_accounts_attributes[]', SocialAccount.new do |ff|
      = ff.hidden_field :social_service_id, value: social_account.social_service.id
      .flex.flex-wrap.mb-6
        = ff.label :service_name, "#{social_account.social_service.name} URL", class: 'block tracking-wide text-gray-700 text-xs font-bold mb-2'
        div class="flex flex-row rounded overflow-hidden w-full border border-gray-300"
          - if social_account.social_service.base_url.present?
            div class='appearance-none block bg-gray-300 text-gray-800 py-3 px-4 leading-tight'
              = social_account.social_service.base_url
          = ff.text_field :identifier, value: social_account.identifier, class: 'appearance-none block w-full bg-gray-100 text-gray-700 py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500'
  .form-group
    = f.submit class: 'inline-block cursor-pointer no-underline text-sm py-3 px-4 mt-4 leading-none rounded lg:mt-0 hover:border-transparent border border-gray-300'

これでフォームの見た目はできた。
Tailwindの参考:Simple rounded text form by perrogrun | Forms, Inputs

更新アクション

ルーティング

social_accounts_controllerupsert_allアクションを追加する。
このupsert_allの定義の仕方については、【Rails】複数レコードを一括操作するルーティングに詳しく書いた。

1
2
3
4
5
6
7
8
9
Rails.application.routes.draw do
  namespace :settings do
    resources :social_accounts, only: %i[index] do
      collection do
        put '', action: :upsert_all
      end
    end
  end
end

コントローラ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
module Settings
  class SocialAccountsController < ApplicationController
    # indexは省略

    def upsert_all
      social_accounts_params.each do |attribute|
        SocialAccount.create_or_update_or_destroy(
          user_id: current_user.id,
          social_service_id: attribute[:social_service_id],
          identifier: attribute[:identifier],
        )
      end
      redirect_to settings_social_accounts_path, notice: '外部サービスの情報を更新しました!'
    end

    private

    # all_social_accountsは省略

    def social_accounts_params
      params.require(:user)
            .permit(social_accounts_attributes: [:social_service_id, :identifier])
            .dig(:social_accounts_attributes)
    end
  end
end

SocialAccountにクラスメソッドを追加

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class SocialAccount < ApplicationRecord
  # 省略

  validates :identifier, presence: true

  def self.create_or_update_or_destroy(user_id:, social_service_id:, identifier:)
    social_account = find_or_initialize_by(
      user_id: user_id,
      social_service_id: social_service_id,
    )
    # 更新に失敗 = identifierが空なので、レコードを削除する
    social_account.update(identifier: identifier) || social_account.destroy!
  end
end

本当はupdateの成功可否でdestroyするのはよくないですが、とりあえずはこれでヨシとします。

Share on

END
END
@aiandrox

目次