🦓

[Rails]ポリモーフィック関連付け④:投稿ステータス

2023/07/28に公開

はじめに

投稿に公開を予約する機能を追加していきます。

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
db/migrate/xxx_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) ==========================

関連付けを設定する

app/models/blog.rb
class Blog < ApplicationRecord
...
  has_one :status, as: :statusable, dependent: :destroy
  scope :published, -> { joins(:status).where(statuses: { name: 1 }) }

  accepts_nested_attributes_for :status
end
app/models/status.rb
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

ローケルファイルを編集する

ステータスの訳文を追加します。

config/locales/ja.yml
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コントローラーを編集する

ステータス関連のパラメーターを追加します。
ユーザーのログイン状態によって公開された投稿に条件分岐を追加します。
ログインしていれば全ての投稿を見ることができますが、そうでない場合公開された投稿だけを表示させるようにします。

app/controllers/blogs_controller.rb
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

https://guides.rubyonrails.org/association_basics.html#methods-added-by-belongs-to-build-association-attributes

ヘルパーメソッドを作成する

ステータスの作成および更新用にヘルパーメソッドを作成します。
ステータスによって表示を切り替えたいのでそちらのヘルパーメソッドも作成します。
ユーザーがステータスを選択せずブログを作成および更新した場合ステータスを下書きにします。

app/helpers/status_helper.rb
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
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
    include StatusHelper
end

フォームにプールダウンセレクトを追加する

日付を選択しない場合下書きとして保存させたいのでinclude_blank: trueを追加します。

app/views/statuses/_status_form.html.erb
...
<%= 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 %>
app/views/blogs/_form.html.erb
...
<%= render 'statuses/status_form', form: form %>

ブログの公開状態をビューに追加する

投稿タイトルの横や上に設置すると分かりやすいでしょう。

一覧ページ

app/views/blogs/index.html.erb
...
<%= render 'blogs/blog', { blog: blog, status: blog.status } %>

詳細ページ

app/views/blogs/show.html.erb
...
<%= render 'blogs/blog', { blog: @blog, status: @blog.status } %>

ステータスペーシャル

app/views/statuses/_status.html.erb
...
<% 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のロジックを追加する

  • 記事のステータスを「公開中」または「公開予約」、公開日時を「未来の日付」に設定して、「更新する」ボタンを押した場合記事のステータスを「公開予約」に変更する
  • 記事のステータスを「公開中」または「公開予約」、公開日時を「過去の日付」に設定して、「更新する」ボタンを押した場合記事のステータスを「公開中」に変更する
  • 記事のステータスを「下書き」に設定して、「更新する」ボタンを押した場合記事のステータスを「下書き」に変更する

これらの条件を満たすためにロジックを追加していきます。
三項演算子を使うとコードをさらに短くなります。

app/helpers/status_helper.rb
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

フォームにタイムピッカを追加する

app/views/statuses/_status_form.html.erb
<div>
    <%= status.label :published_at %>
    <%= status.datetime_select :published_at, { ampm: true, include_blank: true } %>
</div>

https://railsdoc.com/page/datetime_select

予約公開日を表示させる

app/views/statuses/_status.html.erb
...
<% if blog.status.published_at.present? %>
      <span><%= blog.status.published_at %></span>
<% else %>
      <span><%= blog.created_at %></span>
<% end %>

ローケルファイルに日付の訳文を追加する

config/locales/ja.yml
ja:
  date:
    order:
      - :year
      - :month
      - :day
    month_names:
      - 1- 2- 3- 4- 5- 6- 7- 8- 9- 10- 11- 12

ステータスの絞り込み表示

ステータスをクリックすると、ステータスを絞り込んだ投稿一覧を表示させたいので実装していきます。

routes.rbを編集する

IDだと分かりずらいのでパラメーターを名前にします。

config/routes.rb
Rails.application.routes.draw do
...
  resources :statuses, only: %i[index show], param: :name
end

ステータスにリンクを追加する

app/views/statuses/_status.html.erb
...
+ <%= 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コントローラーを作成する

app/controllers/statuses_controller.rb
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]]

ビューを作成する

app/views/statuses/show.html.erb
<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