[Rails]ポリモーフィック関連付け④:投稿ステータス
はじめに
投稿に公開を予約する機能を追加していきます。
Railsで予約投稿を実現する方法はいくつかありますが、今回ではStatusモデルにpublished_at
カラムを追加します。
投稿の予約日時と現在日時を比較して、予約投稿を実現する方法があります。
この場合、カラムの値を監視して、予約された日時が来たら自動的に投稿を公開するような処理を実装します。
環境
Rails 7.0.4.3
ruby 3.2.1
statusモデルを作成する
bin/rails generate model Status name:integer statusable_type:integer statusable_id:integer
invoke active_record
create db/migrate/20230728140325_create_statuses.rb
class CreateStatuses < ActiveRecord::Migration[7.0]
def change
create_table :statuses do |t|
t.integer :name
t.string :statusable_type, null: false
t.integer :statusable_id, null:false
t.timestamps
end
add_index :statuses, [:statusable_id, :statusable_type]
end
end
bin/rails db:migrate
== 20230728140325 CreateStatuses: migrating ===================================
-- create_table(:statuses)
-> 0.0068s
-- add_index(:statuses, [:statusable_id, :statusable_type])
-> 0.0017s
== 20230728140325 CreateStatuses: migrated (0.0085s) ==========================
関連付けを設定する
class Blog < ApplicationRecord
...
has_one :status, as: :statusable, dependent: :destroy
scope :published, -> { joins(:status).where(statuses: { name: 1 }) }
accepts_nested_attributes_for :status
end
class Status < ApplicationRecord
enum status: { draft: 0, published: 1, scheduled: 2 }
belongs_to :statusable, polymorphic: true
validates :name, uniqueness: { scope: [:statusable_id, :statusable_type] }
end
ローケルファイルを編集する
ステータスの訳文を追加します。
ja:
enums:
status:
draft: 下書き
published: 公開中
scheduled: 公開予約
irb(main):002:0> Status.names
=> {"draft"=>0, "published"=>1, "scheduled"=>2}
irb(main):003:0> Status.names.map{|name,value|[name,value]}
=> [["draft", 0], ["published", 1], ["scheduled", 2]]
irb(main):004:0> Status.names.map { |name,value| [I18n.t("enums.status.name.#{name}"), value] }
=> [["下書き", 0], ["公開中", 1], ["公開予約", 2]]
irb(main):005:0> Status.names.keys.map { |name| [I18n.t("enums.status.name.#{name}"), name] }
=> [["下書き", "draft"], ["公開中", "published"], ["公開予約", "scheduled"]]
Blogコントローラーを編集する
ステータス関連のパラメーターを追加します。
ユーザーのログイン状態によって公開された投稿に条件分岐を追加します。
ログインしていれば全ての投稿を見ることができますが、そうでない場合公開された投稿だけを表示させるようにします。
class BlogsController < ApplicationController
...
def new
@blog = Blog.new
+ @blog.build_status
end
def index
+ @blogs = user_signed_in? ? Blog.includes(:user).order(published_at: :desc) : Blog.includes(:user).published.order(published_at: :desc)
end
def create
@blog = current_user.blogs.build(blog_params)
if @blog.save
+ if params.dig(:blog, :status_attributes, :name).present?
+ status = params[:blog][:status_attributes][:name]
+ create_or_update_status_for_model(@blog, status)
+ end
redirect_to @blog
else
render :new, status: :unprocessable_entity
end
end
def edit
@blog.build_status unless @blog.status
end
def update
if @blog.update(blog_params)
+ status = params[:blog][:status_attributes][:name]
+ create_or_update_status_for_model(@blog, status)
redirect_to @blog
else
render :edit, status: :unprocessable_entity
end
end
private
def blog_params
params.require(:blog).permit(:title, :content, status_attributes: [:name, :id])
end
def set_blog
- @blog = Blog.find(params[:id])
+ @blog = user_signed_in? ? Blog.find(params[:id]) : Blog.published.find(params[:id])
rescue ActiveRecord::RecordNotFound
redirect_to root_path
end
end
build_status
メソッドはRailsのhas_one
関連付けを使用する際に自動的に生成されるメソッドの一つです。has_one
は、1対1の関係を表す際に使用されるアソシエーションです。
def build_status
self.build_status unless self.status
end
ヘルパーメソッドを作成する
ステータスの作成および更新用にヘルパーメソッドを作成します。
ステータスによって表示を切り替えたいのでそちらのヘルパーメソッドも作成します。
ユーザーがステータスを選択せずブログを作成および更新した場合ステータスを下書き
にします。
module StatusHelper
def create_or_update_status_for_model(statusable, status)
if status.present?
if statusable.status.present?
statusable.status.update(name: status)
else
status = Status.find_or_create_by(name: status, statusable: statusable)
statusable.status = status
end
else
statusable.status.update(name: 0, published_at: nil)
end
rescue ActiveRecord::RecordInvalid
false
end
def status_badge_class(status)
case status
when 'published'
"bg-green-50 text-green-700 ring-green-600/20"
when 'draft'
"bg-gray-50 text-gray-600 ring-grat-500/10"
when 'scheduled'
"bg-indigo-50 text-indigo-700 ring-indigo-700/10"
end
end
end
class ApplicationController < ActionController::Base
include StatusHelper
end
フォームにプールダウンセレクトを追加する
日付を選択しない場合下書き
として保存させたいのでinclude_blank: true
を追加します。
...
<%= form.fields_for :status do |status| %>
<div>
<%= status.label :status %>
<%= status.select :name, Status.names.keys.map { |name| [I18n.t("enums.status.name.#{name}"), name] }, include_blank: true %>
</div>
<% end %>
...
<%= render 'statuses/status_form', form: form %>
ブログの公開状態をビューに追加する
投稿タイトルの横や上に設置すると分かりやすいでしょう。
一覧ページ
...
<%= render 'blogs/blog', { blog: blog, status: blog.status } %>
詳細ページ
...
<%= render 'blogs/blog', { blog: @blog, status: @blog.status } %>
ステータスペーシャル
...
<% if statusable.status.name.present? %>
<span class="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset
<%= status_badge_class(statusable.status.name)%>">
<%= t("enums.status.name.#{statusable.status.name}") %></span>
<% end %>
ログの動きを確認します。
Status Create (0.2ms) INSERT INTO "statuses" ("name", "statusable_type", "statusable_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5) RETURNING "id" [["name", 0], ["statusable_type", "Blog"], ["statusable_id", 13], ["created_at", "2023-07-29 15:36:07.301472"], ["updated_at", "2023-07-29 15:36:07.301472"]]
15:36:07 web.1 | ↳ app/controllers/blogs_controller.rb:18:in `create'
15:36:07 web.1 | TRANSACTION (0.4ms) COMMIT
Status Update (0.2ms) UPDATE "statuses" SET "name" = $1, "updated_at" = $2 WHERE "statuses"."id" = $3 [["name", 1], ["updated_at", "2023-07-29 15:37:13.609902"], ["id", 15]]
15:37:13 web.1 | ↳ app/controllers/blogs_controller.rb:34:in `update'
15:37:13 web.1 | TRANSACTION (0.6ms) COMMIT
DBに保存されることも確認します。
rails_blog_development=# SELECT * FROM statuses;
id | name | statusable_type | statusable_id | created_at | updated_at
----+------+-----------------+---------------+----------------------------+----------------------------
45 | 1 | Blog | 17 | 2023-07-29 16:33:18.020851 | 2023-07-29 16:33:18.020851
42 | 0 | Blog | 15 | 2023-07-29 16:25:15.411821 | 2023-07-29 16:33:26.817827
47 | 1 | Blog | 19 | 2023-07-29 16:34:02.217541 | 2023-07-29 16:34:02.217541
(3 rows)
投稿にステータスを追加しました。
次にpublished_at
カラムを追加し公開する日付を設定できるようにします。
published_at
カラムを追加する
bin/rails generate migration AddPublishedAtToStatuses published_at:datetime
invoke active_record
create db/migrate/20230729104755_add_published_at_to_statuses.rb
bin/rails db:migrate
-- add_column(:statuses, :published_at, :datetime)
-> 0.0046s
== 20230729104755 AddPublishedAtToStatuses: migrated (0.0047s) ================
published_at
のロジックを追加する
- 記事のステータスを「公開中」または「公開予約」、公開日時を「未来の日付」に設定して、「更新する」ボタンを押した場合記事のステータスを「公開予約」に変更する
- 記事のステータスを「公開中」または「公開予約」、公開日時を「過去の日付」に設定して、「更新する」ボタンを押した場合記事のステータスを「公開中」に変更する
- 記事のステータスを「下書き」に設定して、「更新する」ボタンを押した場合記事のステータスを「下書き」に変更する
これらの条件を満たすためにロジックを追加していきます。
三項演算子を使うとコードをさらに短くなります。
module StatusHelper
def create_or_update_status_for_model(statusable, status)
...
if statusable.status&.name == 'published' && statusable.status&.published_at.nil?
statusable.status&.update(name: 'published', published_at: nil)
elsif statusable.status&.name == 'draft' || statusable.status&.published_at.nil?
statusable.status&.update(name: 'draft', published_at: nil)
elsif statusable.status&.name != 'draft' && statusable.status&.published_at.present?
statusable.status&.update(name: (statusable.status.published_at > Time.current) ? 'scheduled' : 'published')
end
...
end
end
if statusable.status.published_at.present? && statusable.status.published_at > Time.current
statusable.status.update(name: 2)
elsif statusable.status.published_at.present? && statusable.status.published_at <= Time.current
statusable.status.update(name: 1)
end
フォームにタイムピッカを追加する
<div>
<%= status.label :published_at %>
<%= status.datetime_select :published_at, { ampm: true, include_blank: true } %>
</div>
予約公開日を表示させる
...
<% if blog.status.published_at.present? %>
<span><%= blog.status.published_at %></span>
<% else %>
<span><%= blog.created_at %></span>
<% end %>
ローケルファイルに日付の訳文を追加する
ja:
date:
order:
- :year
- :month
- :day
month_names:
- 1月
- 2月
- 3月
- 4月
- 5月
- 6月
- 7月
- 8月
- 9月
- 10月
- 11月
- 12月
ステータスの絞り込み表示
ステータスをクリックすると、ステータスを絞り込んだ投稿一覧を表示させたいので実装していきます。
routes.rb
を編集する
IDだと分かりずらいのでパラメーターを名前にします。
Rails.application.routes.draw do
...
resources :statuses, only: %i[index show], param: :name
end
ステータスにリンクを追加する
...
+ <%= link_to status_path(name: status.name) do %>
<span class="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset
<%= status_badge_class(statusable.status.name)%>">
<%= t("enums.status.name.#{statusable.status.name}") %>
</span>
+ <% end %>
Statusコントローラーを作成する
class StatusesController < ApplicationController
def index
@statuses = Status.includes(:statusable)
end
def show
@status = Status.find_by(name: params[:name])
@items = find_items_with_status(@status)
end
private
def find_items_with_status(status)
return [] unless status
Status.where(name: status.name).includes(:statusable)map(&:statusable)
end
end
# eager loadなし
irb(main):012:0> Status.where(name:0).map(&:statusable)
Status Load (0.4ms) SELECT "statuses".* FROM "statuses" WHERE "statuses"."name" = $1 [["name", 0]]
Blog Load (0.3ms) SELECT "blogs".* FROM "blogs" WHERE "blogs"."id" = $1 LIMIT $2 [["id", 21], ["LIMIT", 1]]
Blog Load (0.3ms) SELECT "blogs".* FROM "blogs" WHERE "blogs"."id" = $1 LIMIT $2 [["id", 15], ["LIMIT", 1]]
Blog Load (0.2ms) SELECT "blogs".* FROM "blogs" WHERE "blogs"."id" = $1 LIMIT $2 [["id", 22], ["LIMIT", 1]]
Post Load (0.1ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = $1 LIMIT $2 [["id", 2], ["LIMIT", 1]]
# eager loadあり
irb(main):013:0> Status.where(name: 0).includes(:statusable).map(&:statusable)
Status Load (0.3ms) SELECT "statuses".* FROM "statuses" WHERE "statuses"."name" = $1 [["name", 0]]
Blog Load (0.3ms) SELECT "blogs".* FROM "blogs" WHERE "blogs"."id" IN ($1, $2, $3) [["id", 21], ["id", 15], ["id", 22]]
Post Load (0.2ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = $1 [["id", 2]]
ビューを作成する
<h1>ステータス:<%= t("enums.status.name.#{@status.name}") %>(<%= @items.count %>)</h1>
<ul>
<% @items.each do |item| %>
<li><%= link_to item.title, item %></li>
<%= render 'statuses/status', statusable: item, status: item.status %>
<% end %>
</ul>
終わり
予約投稿ができると便利ですね。
予約投稿を自動的に実行するためのタスクを設定する必要があります。
次回の記事ではcronジョブを使用して実装していきます。
Discussion