🦓

[Rails] 管理画面の投稿/ユーザのCRUD19/20

2023/07/09に公開

はじめに

管理画面に投稿のCRUD機能とユーザーのCRUD機能を作成していきます。
要件:
ユーザー一覧画面では、ID、姓名、権限(一般 or 管理者)、詳細・編集・削除ボタンの項目が表示されていること
ユーザー詳細画面では、ID、姓名、権限(一般 or 管理者)、作成日時の項目が表示されていること
投稿一覧画面では、ID、タイトル、作成者、作成日、詳細・編集・削除ボタンの項目が表示されていること(タイトル部分をクリックすると詳細画面に遷移すること)
投稿詳細画面にも「編集」「削除」ボタンが表示されていること
一覧、詳細どちらの画面でも削除ボタンをクリックすると確認アラートが表示されること

投稿検索

  • フリーテキスト(タイトル or 本文に含まれているかどうかを判定)
  • 作成日の範囲指定

ユーザー検索

  • 名前
  • 権限(一般 or 管理者 のプルダウン形式)

環境:

Rails 6.1.7.3
ruby 3.0.0

Admin::Articlesコントローラーを作成する

bin/rails g controller admin::articles
      create  app/controllers/admin/articles_controller.rb
      invoke  erb
      create    app/views/admin/articles
app/controllers/admin/articles_controller.rb
class Admin::ArticlesController < Admin::BaseController
    skip_before_action :check_admin
    before_action :set_article, only: %i[show edit update destroy]
  
    def index
      @search = Article.ransack(params[:q])
      @articles = @search.result(distinct: true).includes(:user).order(created_at: :desc).page(params[:page]).per(20)
    end
  
    def show; end
    
    def edit; end
  
    def update
      if @article.update(article_params)
        redirect_to admin_article_path(@article), success: t('defaults.message.updated', item: Article.model_name.human)
      else
        flash.now[:danger] = t('defaults.message.not_updated', item: Article.model_name.human)
        render :edit
      end
    end
  
    def destroy
      @article.destroy!
      redirect_to admin_articles_path, success: t('defaults.message.deleted', item: Article.model_name.human)
    end
  
    private
  
    def set_article
      @article = Article.find(params[:id])
    end
  
    def article_params
      params.require(:article).permit(:title, :body, :article_image, :article_image_cache)
    end
end

Admin::Usersコントローラーを作成する

bin/rails g controller admin::users
      create  app/controllers/admin/users_controller.rb
      invoke  erb
      create    app/views/admin/users
app/controllers/admin/users_controller.rb
class Admin::UsersController < Admin::BaseController
    skip_before_action :check_admin
    before_action :set_user, only: %i[show edit update destroy]
  
    def index
      @search = User.ransack(params[:q])
      @users = @search.result(distinct: true).order(created_at: :desc).page(params[:page]).per(10)
    end
    
    def show; end
  
    def edit; end
  
    def update
      if @user.update(user_params)
        redirect_to admin_user_path(@user), success: t('defaults.message.updated', item: User.model_name.human)
      else
        flash.now[:danger] = t('defaults.message.not_updated', item: User.model_name.human)
        render :edit
      end
    end
  
    def destroy
      @user.destroy!
      redirect_to admin_users_path, success: t('defaults.message.deleted', item: User.model_name.human)
    end
  
  
    private
  
    def set_user
      @user = User.find(params[:id])
    end
  
    def user_params
      params.require(:user).permit(:user_name, :email, :profile, :profile_cache, :role)
    end
end

ルーティングを設定する

confit/routes.rb
namespace :admin do
    resources :users, only: %i[index show edit update destroy]
    resources :articles, only: %i[index show edit update destroy]
end

リソースフルルーティングでは、(GET、PUTなどの)各種HTTP verb(動詞、HTTPメソッドとも呼ばれます) と、コントローラ内アクションを指すURLが対応付けられます。1つのアクションは、データベース上での特定のCRUD(Create/Read/Update/Delete)操作に対応付けられるルールになっています。

ネームスペースルーティングは、コントローラーとビューをグループ化するための仕組みです。ネームスペースを使用すると、関連するコントローラーやビューを特定の名前空間の下に配置し、コードをより構造化して整理することができます。例えば、管理者向けの機能と一般ユーザー向けの機能を分けて管理する場合、管理者用のコントローラーやビューをAdminネームスペースの下に配置することができます。

admin_users GET        /admin/users(.:format)                               admin/users#index
edit_admin_user GET    /admin/users/:id/edit(.:format)                       admin/users#edit
admin_user GET         /admin/users/:id(.:format)                             admin/users#show
PATCH                  /admin/users/:id(.:format)                             admin/users#update
PUT                    /admin/users/:id(.:format)                             admin/users#update
DELETE                 /admin/users/:id(.:format)                             admin/users#destroy

admin_articles GET     /admin/articles(.:format)                             admin/articles#index
edit_admin_article GET /admin/articles/:id/edit(.:format)                     admin/articles#edit
admin_article GET      /admin/articles/:id(.:format)                         admin/articles#show
PATCH                  /admin/articles/:id(.:format)                         admin/articles#update
PUT                    /admin/articles/:id(.:format)                         admin/articles#update
DELETE                 /admin/articles/:id(.:format)                         admin/articles#destroy

ユーザーCRUD

ユーザー一覧ビューを作成する

app/views/admin/users/index.html.erb
<% content_for(:title, t('.title')) %>
<div class="container mb-5 pt-2">
  <h1><%= t('.title') %></h1>
  <div class="row">
    <div class="col-md-12 mb-3">
      <%= render 'search_form' %>
    </div>
  </div>
  <div class="row">
    <div class="col-md-12">
      <table class="table table-striped">
        <thead>
          <tr>
            <th scope="col"><%= User.human_attribute_name(:id) %></th>
            <th scope="col"><%= User.human_attribute_name(:user_name) %></th>
            <th scope="col"><%= User.human_attribute_name(:role) %></th>
            <th scope="col"><%= User.human_attribute_name(:created_at) %></th>
            <th scope="col"></th>
          </tr>
        </thead>
        <tbody>
          <%= render @users %>
        </tbody>
      </table>
    </div>
  </div>
  <div class="row">
    <div class="col-md-12">
      <!-- ページネーション -->
      <%= paginate @users %>
    </div>
  </div>
</div>

<%= render @users %>は、指定された変数 @users の各要素に対して、デフォルトの部分テンプレートを使って描画を行うRailsのビューヘルパーメソッドです。

@users は、コントローラーからビューに渡されるインスタンス変数であり、通常はコントローラーで取得したモデルのコレクションを指します。ビューでは、このコレクションをループ処理して各要素を描画する必要があります。

部分テンプレートは通常、モデルの単数形に基づいて命名され、先頭にアンダースコア _ が付きます。例えば、User モデルの場合は user という名前の部分テンプレート _user.html.erb を使用します。

正式形式は<%= render 'user', collection: @users %> となります。collection オプションを使って、@users コレクションを user 部分テンプレートに渡して描画します。

ユーザーのパーシャルを作成する

app/views/admin/users/_user.html.erb
<tr>
  <td>
    <%= user.id %>
  </td>
  <td>
    <%= user.user_name %>
  </td>
  <td>
    <%= user.role_i18n %>
  </td>
  <td>
    <%= l user.created_at, format: :long %>
  </td>
  <td>
    <%= link_to t('defaults.show'), 
    admin_user_path(user), 
    class: 'btn btn-info' %>
    <%= link_to t('defaults.edit'), 
    edit_admin_user_path(user), 
    class: 'btn btn-success' %>
    <%= link_to t('defaults.delete'), 
    admin_user_path(user), 
    method: :delete, 
    data: { confirm: t('defaults.message.delete_confirm') }, 
    class: 'btn btn-danger' %>
  </td>
</tr>

検索フォームのパーシャルを作成する

app/views/admin/users/_search_form.html.erb
<%= search_form_for @search, url: admin_users_path do |f| %>
  <div class="row">
    <div class="form-inline align-items-center mx-auto">
      <div class="col-auto">
        <%= f.search_field :user_name_cont, 
	class: 'form-control', 
	placeholder: t('defaults.search_word') %>
      </div>
      <div class="col-auto">
        <%= f.select :role_eq, 
	User.roles_i18n.invert.map{|key, value| [key, User.roles[value]]}, 
	{ include_blank: t('defaults.unspecified') }, 
	{ class: 'form-control mr-1' } %>
      </div>
      <div class="col-auto">
        <%= f.submit class: 'btn btn-primary' %>
      </div>
    </div>
  </div>
<% end %>

search_form_for メソッドは、検索用のフォームを作成するためのヘルパーメソッドです。

@search オブジェクトを使用して検索フォームを作成しています。@search は、検索のためのパラメーターを格納するために使用されるオブジェクトです。
url: admin_users_path は、フォームが送信された際のアクション先のURLを指定しています。

<%= f.select :role_eq, ...%> は、ロールの絞り込み用のプルダウンを表示しています。:role_eq は絞り込みのためのカラム名を指定しています。

プルダウンについてはこちらの記事をご参考ください。

検索するカラムを指定する

ユーザー名とロールで検索できるようにしたいのでUserモデルで関連されたいカラムを指定します。
関連させたいモデルがないので空の配列を指定します。

app/models/user.rb
class User < ApplicationRecord
    def self.ransackable_attributes(auth_object = nil)
        ["user_name", "role"]
    end
    
    def self.ransackable_associations(auth_object = nil)
        []
    end
end

https://activerecord-hackery.github.io/ransack/going-further/other-notes/#authorization-allowlistingdenylisting

ユーザーの詳細ビューを作成する

app/views/admin/user/show.html.erb
<% content_for(:title, t('.title')) %>
<div class="container">
  <div class="row">
    <div class="col-md-10 offset-md-1 col-lg-8 offset-lg-2">
      <h1><%= t('.title') %></h1>
      <div class="text-right mb-3">
        <%= link_to t('defaults.edit'), edit_admin_user_path(@user), class: 'btn btn-success' %>
        <%= link_to t('defaults.delete'), admin_user_path(@user), method: :delete, data: { confirm: t('defaults.message.delete_confirm') }, class: 'btn btn-danger' %>
      </div>
      <table class="table table-bordered bg-white">
        <tr>
          <th scope="row"><%= User.human_attribute_name(:id) %></th>
          <td><%= @user.id %></td>
        </tr>
        <tr>
          <th scope="row"><%= User.human_attribute_name(:role) %></th>
          <td><%= @user.role_i18n %></td>
        </tr>
        <tr>
          <th scope="row"><%= User.human_attribute_name(:full_name) %></th>
          <td><%= @user.user_name %></td>
        </tr>
        <tr>
          <th scope="row"><%= User.human_attribute_name(:avatar) %></th>
          <td><%= image_tag @user.profile.thumb.url %></td>
        </tr>
        <tr>
          <th scope="row"><%= User.human_attribute_name(:created_at) %></th>
          <td><%= l @user.created_at, format: :long %></td>
        </tr>
      </table>
    </div>
  </div>
</div>

ユーザーの編集ビューを作成する

app/views/admin/user/edit.html.erb
<% content_for(:title, t('.title')) %>
<div class="container">
  <div class="row">
    <div class="col-md-10 offset-md-1 col-lg-8 offset-lg-2">
      <h1><%= t '.title' %></h1>
      <%= form_with model: @user, url: admin_user_path(@user), local: true do |f| %>
        <%= render 'shared/error_messages', object: f.object %>
        <div class="form-group">
          <%= f.label :email %>
          <%= f.email_field :email, class: 'form-control' %>
        </div>
        <div class="form-group">
          <%= f.label :user_name %>
          <%= f.text_field :first_name, class: 'form-control' %>
        </div>
        <div class="form-group mb-3">
          <%= f.label :profile %>
          <%= f.file_field :profile, class: "form-control js-file-select-preview", accept: 'image/*', data: { target: '#preview-target' }  %>
          <%= f.hidden_field :profile_cache %>
        </div>
        <div class='form-group mb-3'>
          <% if @user.profile.present? %>
            <%= image_tag @user.profile.url %>
          <% else %>
            <%= image_tag 'user.png', id: 'preview-target', size:'50x50', class: 'round-circle' %>
          <% end %>
        </div>
        <div class="form-group">
          <%= f.label :role %>
          <%= f.select :role, User.roles_i18n.invert, {}, class: 'form-control' %>
        </div>
        <%= f.submit class: 'btn btn-primary' %>
      <% end %>
    </div>
  </div>
</div>

投稿CRUD

投稿一覧ビューを作成する

app/views/admin/articles/index.html.erb
<% content_for(:title, t('.title')) %>
<div class="container mb-5 pt-2">
  <h1><%= t('.title') %></h1>
  <div class="row">
    <div class="col-md-12 mb-3">
      <%= render 'search_form' %>
    </div>
  </div>
  <div class="row">
    <div class="col-md-12">
      <table class="table table-striped">
        <thead>
          <tr>
            <th scope="col"><%= Article.human_attribute_name(:id) %></th>
            <th scope="col"><%= Article.human_attribute_name(:title) %></th>
            <th scope="col"><%= Article.human_attribute_name(:user) %></th>
            <th scope="col"><%= Article.human_attribute_name(:created_at) %></th>
            <th scope="col"></th>
          </tr>
        </thead>
        <tbody>
          <%= render @articles %>
        </tbody>
      </table>
    </div>
  </div>
  <div class="row">
    <div class="col-md-12">
      <!-- ページネーション -->
      <%= paginate @articles %>
    </div>
  </div>
</div>

投稿のパーシャルを作成する

app/views/admin/articles/_article.html.erb
<tr>
  <td>
    <%= article.id %>
  </td>
  <td>
    <%= article.title %>
  </td>
  <td>
    <%= article.user.user_name %>
  </td>
  <td>
    <%= l article.created_at, format: :long %>
  </td>
  <td>
    <%= link_to t('defaults.show'), admin_article_path(article), class: 'btn btn-info' %>
    <%= link_to t('defaults.edit'), edit_admin_article_path(article), class: 'btn btn-success' %>
    <%= link_to t('defaults.delete'), admin_article_path(article), method: :delete, data: { confirm: t('defaults.message.delete_confirm') }, class: 'btn btn-danger' %>
  </td>
</tr>

検索フォームのパーシャルを作成する

app/views/admin/articles/_search_form.html.erb
<%= search_form_for @search, url: admin_articles_path do |f| %>
  <div class="row">
    <div class="form-inline align-items-center mx-auto">
      <div class="col-auto">
        <%= f.search_field :title_or_body_cont, class: 'form-control', placeholder: t('defaults.search_word') %>
      </div>
      <div class="col-auto">
        <%= f.date_field :created_at_gteq, class: 'form-control' %>
        <span>~</span>
        <%= f.date_field :created_at_lteq, class: 'form-control' %>
      </div>
      <div class="col-auto">
        <%= f.submit class: 'btn btn-primary' %>
      </div>
    </div>
  </div>
<% end %>

検索するカラムを指定する

本文、タイトルと日付で検索できるようにしたいのでArticleモデルで関連されたいカラムを指定します。
関連させたいモデルがないので空の配列を指定します。

app/models/article.rb
class Article < ApplicationRecord
    def self.ransackable_attributes(auth_object = nil)
        %w[title body created_at]
    end

    def self.ransackable_associations(auth_object = nil)
        []
    end
end

タイムゾーンの設定

時間指定の検索においてアプリとDBのタイムゾーンを一致する必要があります。

config/application.rb
class Application < Rails::Application
    config.time_zone = 'Tokyo'
    config.active_record.default_timezone = :local

発行されたSQL文を確認したところ、6月30までの投稿を探していますが、2023-06-30 00:00:00になっています。

Article Load (0.7ms)  SELECT DISTINCT "articles".* FROM "articles" WHERE ("articles"."created_at" >= '2023-06-29 00:00:00' AND "articles"."created_at" <= '2023-06-30 00:00:00') ORDER BY "articles"."created_at" DESC LIMIT ? OFFSET ?  [["LIMIT", 20], ["OFFSET", 0]]

end_of_dayを使う

irb(main):004:0> Time.current.end_of_day
=> Mon, 10 Jul 2023 23:59:59.999999999 JST +09:00
irb(main):005:0> Time.current.beginning_of_day
=> Mon, 10 Jul 2023 00:00:00.000000000 JST +09:00

https://apidock.com/rails/DateTime/end_of_day

検索フォームで使ったlteqをカスタマイズし、日付の終了時間を変更します。

config/initializers/ransack.rb
Ransack.configure do |config|
    config.add_predicate 'lteq',
      arel_predicate: 'lteq',
      formatter: proc { |v| v.end_of_day },
      validator: proc { |v| v.present? },
      type: :date
  end

config.add_predicate 'lteq', - 'lteq'という名前のプレディケート(条件)を追加します。

arel_predicate: 'lteq', - Arelのプレディケートとして 'lteq' を使用します。これは「以下または等しい」を意味します。

formatter: proc { |v| v.end_of_day }, - 値をフォーマットするための処理を指定します。この場合、与えられた値 v の末尾(終了)時刻を表す end_of_day メソッドを適用します。これにより、日付の終了時刻が設定されます。

validator: proc { |v| v.present? }, - 値の妥当性を検証するための処理を指定します。この場合、与えられた値 v が存在しているかどうかをチェックします。空でない場合のみ妥当とみなされます。

type: :date - プレディケートのタイプを :date として指定します。これにより、検索フォームなどで日付の範囲検索を行う際に利用できるようになります。

SQL文をもう一度確認してみます。
終了時間がちゃんと反映されました。

(0.2ms)  SELECT COUNT(DISTINCT "articles"."id") FROM "articles" WHERE ("articles"."created_at" >= '2023-06-29 00:00:00' AND "articles"."created_at" <= '2023-06-30 23:59:59.999999')

投稿の詳細ビューを作成する

app/views/admin/articles/show.html.erb
<% content_for(:title, @article.title) %>
<div class="container">
  <div class="row">
    <div class="col-md-10 offset-md-1 col-lg-8 offset-lg-2">
      <h1><%= t('.title') %></h1>
      <div class="text-right mb-3">
        <%= link_to t('defaults.edit'), edit_admin_article_path(@article), class: 'btn btn-success' %>
        <%= link_to t('defaults.delete'), admin_board_path(@article), method: :delete, data: { confirm: t('defaults.message.delete_confirm') }, class: 'btn btn-danger' %>
      </div>
      <table class="table table-bordered bg-white">
        <tr>
          <th scope="row"><%= Board.human_attribute_name(:id) %></th>
          <td><%= @article.id %></td>
        </tr>
        <tr>
          <th scope="row"><%= Board.human_attribute_name(:title) %></th>
          <td><%= @article.title %></td>
        </tr>
        <tr>
          <th scope="row"><%= Board.human_attribute_name(:user) %></th>
          <td><%= @article.user.user_name %></td>
        </tr>
        <tr>
          <th scope="row"><%= Board.human_attribute_name(:body) %></th>
          <td><%= @article.body %></td>
        </tr>
        <tr>
          <th scope="row"><%= Board.human_attribute_name(:created_at) %></th>
          <td><%= l @article.created_at, format: :long %></td>
        </tr>
      </table>
    </div>
  </div>
</div>

投稿の編集ビューを作成する

app/views/admin/articles/edit.html.erb
<% content_for(:title, @article.title) %>
<div class="container">
  <div class="row">
    <div class="col-md-10 offset-md-1 col-lg-8 offset-lg-2">
      <h1><%= t '.title' %></h1>
      <%= form_with model: @article, url: admin_article_path(@article), local: true do |f| %>
        <%= render 'shared/error_messages', object: f.object %>
        <div class="form-group">
          <%= f.label :title %>
          <%= f.text_field :title, class: 'form-control' %>
        </div>
        <div class="form-group">
          <%= f.label :body %>
          <%= f.text_area :body, class: 'form-control', rows: 10 %>
        </div>
        <div class="form-group mb-3">
          <%= f.label :image %>
          <%= f.file_field :image, class:'form-control js-file-select-preview', accept: 'image/*', data: { target: '#preview-target' }  %>
          <%= f.hidden_field :image_cache %>
        </div>
        <div class="form-group mt-3 mb-3">
          <% if @article.image.present? %>
            <%= image_tag article.image.url %>
          <% else %>
            <%= image_tag 'no-image.jpg', id: 'preview-target', size:'300x250'%>
          <% end %>
        </div>
        <%= f.submit class: 'btn btn-primary' %>
      <% end %>
    </div>
  </div>
</div>

訳文を作成する

locales/activerecord/ja.yml
ja:
  activerecord:
    enums:
      user:
        role:
          general: '一般'
          admin: '管理者'
locales/views/ja.yml
ja:
  defaults:
    unspecified: '指定なし'

admin:
    users:
      index:
        title: 'ユーザー一覧'
      show:
        title: 'ユーザー詳細'
      edit:
        title: 'ユーザー編集'
    articles:
      index:
        title: '投稿一覧'
      show:
        title: '投稿詳細'
      edit:
        title: '投稿編集'

サイドメニューのアクティブ化

どのメニューがクリックされているのかを分かりやすくしたいので現在いるパスにactiveクラスを追加します。

ヘルパーメソッドを定義する

controller_pathであればactiveを返します。

app/helpers/application_helper.rb
module ApplicationHelper
  def active_if(path)
    path == controller_path ? 'active' : ''
  end
end
irb(main):009:0> Admin::UsersController.controller_path
=> "admin/users"
irb(main):010:0> Admin::ArticlesController.controller_path
=> "admin/articles"

ビューでパスを指定する

app/views/admin/shared/_sidebar.html.erb
<li class="nav-item">
    <%= link_to admin_articles_path, class: "nav-link #{ active_if('admin/articles') }" do %>
      <i class="nav-icon far fa-file"></i>
      <p>
        <%= t('admin.articles.index.title') %>
      </p>
    <% end %>
<li class="nav-item">
    <%= link_to admin_users_path, class: "nav-link #{ active_if('admin/users') }" do %>
       <i class="nav-icon far fa-user"></i>
       <p>
         <%= t('admin.users.index.title') %>
       </p>
     <% end %>
</li>

終わりに

管理画面を初めて実装しました。
namespaceルーティングが便利でした。

Discussion