[Rails] 管理画面の投稿/ユーザのCRUD19/20
はじめに
管理画面に投稿の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
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
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
ルーティングを設定する
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
ユーザー一覧ビューを作成する
<% 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
部分テンプレートに渡して描画します。
ユーザーのパーシャルを作成する
<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>
検索フォームのパーシャルを作成する
<%= 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
モデルで関連されたいカラムを指定します。
関連させたいモデルがないので空の配列を指定します。
class User < ApplicationRecord
def self.ransackable_attributes(auth_object = nil)
["user_name", "role"]
end
def self.ransackable_associations(auth_object = nil)
[]
end
end
ユーザーの詳細ビューを作成する
<% 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>
ユーザーの編集ビューを作成する
<% 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
投稿一覧ビューを作成する
<% 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>
投稿のパーシャルを作成する
<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>
検索フォームのパーシャルを作成する
<%= 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
モデルで関連されたいカラムを指定します。
関連させたいモデルがないので空の配列を指定します。
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のタイムゾーンを一致する必要があります。
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
検索フォームで使ったlteq
をカスタマイズし、日付の終了時間を変更します。
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')
投稿の詳細ビューを作成する
<% 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>
投稿の編集ビューを作成する
<% 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>
訳文を作成する
ja:
activerecord:
enums:
user:
role:
general: '一般'
admin: '管理者'
ja:
defaults:
unspecified: '指定なし'
admin:
users:
index:
title: 'ユーザー一覧'
show:
title: 'ユーザー詳細'
edit:
title: 'ユーザー編集'
articles:
index:
title: '投稿一覧'
show:
title: '投稿詳細'
edit:
title: '投稿編集'
サイドメニューのアクティブ化
どのメニューがクリックされているのかを分かりやすくしたいので現在いるパスにactive
クラスを追加します。
ヘルパーメソッドを定義する
controller_path
であればactive
を返します。
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"
ビューでパスを指定する
<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