管理画面 掲示板/ユーザのCRUD機能の作成
メニューのアクティブ・非アクティブ化
app/helpers/application_helper.rb
def active_if(path) path == controller_path ? 'active' : '' end
三項演算子を使って、真のときはactive、偽のときは何も返さない('')ようにします。 controller_pathでコントローラー名を取得できる。
app/views/admin/shared/_sidebar.html.erb
<%= link_to admin_boards_path, class: "nav-link #{active_if('admin/boards')}" do %> <%= link_to admin_users_path, class: "nav-link #{active_if('admin/users')}" do %>
ヘルパーメソッドを使って、現在のコントローラーがadmin/boardspathと一致しているときは、activeを返す。 するとclass: "nav-link active"となるので、アクティブになる。
EnumHelp導入
EnumHelpとは、Enumで定義した値を簡単に翻訳できるgem。
gem 'enum_help'
bundle install
config/locales/activerecord/ja.yml
enums: user: role: general: '一般' admin: '管理者'
Controller設定
app/controllers/admin/boards_controller.rb
管理者権限の掲示板の設定
class Admin::BoardsController < Admin::BaseController before_action :set_board, only: %i[edit update show destroy] def index # ransackのプルダウン検索実装 @q = Board.ransack(params[:q]) @boards = @q.result(distinct: true).includes(:user).order(created_at: :desc).page(params[:page]) end def edit; end def update if @board.update(board_params) redirect_to admin_board_path(@board), success: t('defaults.message.updated', item: Board.model_name.human) else flash.now['danger'] = t('defaults.message.not_updated', item: Board.model_name.human) render :edit end end def show; end def destroy @board.destroy! redirect_to admin_boards_path, success: t('defaults.message.deleted', item: Board.model_name.human) end private def set_board @board = Board.find(params[:id]) end def board_params params.require(:board).permit(:title, :body, :board_image, :board_image_cache) end end
app/controllers/admin/users_controller.rb
管理者権限ユーザーの設定
class Admin::UsersController < Admin::BaseController before_action :set_user, only: %i[edit update show destroy] def index # ransackのプルダウン検索実装 @q = User.ransack(params[:q]) @users = @q.result(distinct: true).order(created_at: :desc).page(params[:page]) 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 show; 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(:email, :last_name, :first_name, :avatar, :avatar_cache, :role) end end
ルーティングの設定
resources :boards, only: %i[index edit update show destroy] resources :users, only: %i[index edit update show destroy]
Viewの設定
・掲示板Viewの設定
app/views/admin/boards/_board.html.erb
<tr> <td> <%= board.id %> </td> <td> <%= board.title %> </td> <td> <%= board.user.decorate.full_name %> </td> <td> <%= l board.created_at, format: :long %> </td> <td> <%= link_to t('defaults.show'), admin_board_path(board), class: 'btn btn-info' %> <%= link_to t('defaults.edit'), edit_admin_board_path(board), class: 'btn btn-success' %> <%= link_to t('defaults.delete'), admin_board_path(board), method: :delete, data: { confirm: t('defaults.message.delete_confirm') }, class: 'btn btn-danger' %> </td> </tr>
app/views/admin/boards/_search_form.html.erb
<%= search_form_for @q, url: admin_boards_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_end_of_day, class: 'form-control' %> </div> <div class="col-auto"> <%= f.submit class: 'btn btn-primary' %> </div> </div> </div> <% end %>
app/views/admin/boards/edit.html.erb
<% content_for(:title, @board.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: @board, url: admin_board_path(@board), 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"> <%= f.label :board_image %> <%= f.file_field :board_image, onchange: 'previewFileWithId(preview)', class: 'form-control mb-3', accept: 'image/*' %> <%= f.hidden_field :board_image_cache %> </div> <div class='mt-3 mb-3'> <%= image_tag @board.board_image.url, id: 'preview', size: '300x200' %> </div> <%= f.submit class: 'btn btn-primary' %> <% end %> </div> </div> </div>
app/views/admin/boards/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-sm-12"> <table class="table table-striped"> <thead> <tr> <th scope="col"><%= Board.human_attribute_name(:id) %></th> <th scope="col"><%= Board.human_attribute_name(:title) %></th> <th scope="col"><%= Board.human_attribute_name(:user) %></th> <th scope="col"><%= Board.human_attribute_name(:created_at) %></th> <th scope="col"></th> </tr> </thead> <tbody> <%= render @boards %> </tbody> </table> </div> </div> <div class="row"> <div class="col-sm-12"> <!-- ページネーション --> <%= paginate @boards %> </div> </div> </div>
app/views/admin/boards/show.html.erb
<% content_for(:title, @board.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_board_path(@board), class: 'btn btn-success' %> <%= link_to t('defaults.delete'), admin_board_path(@board), 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><%= @board.id %></td> </tr> <tr> <th scope="row"><%= Board.human_attribute_name(:title) %></th> <td><%= @board.title %></td> </tr> <tr> <th scope="row"><%= Board.human_attribute_name(:user) %></th> <td><%= @board.user.decorate.full_name %></td> </tr> <tr> <th scope="row"><%= Board.human_attribute_name(:body) %></th> <td><%= @board.body %></td> </tr> <tr> <th scope="row"><%= Board.human_attribute_name(:created_at) %></th> <td><%= l @board.created_at, format: :long %></td> </tr> </table> </div> </div> </div>
・ユーザーViewの設定
app/views/admin/users/_search_form.html.erb
<%= search_form_for @q, 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 :first_name_or_last_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 %>
app/views/admin/users/_user.html.erb
<tr> <td> <%= user.id %> </td> <td> <%= user.decorate.full_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/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 :last_name %> <%= f.text_field :last_name, class: 'form-control' %> </div> <div class="form-group"> <%= f.label :first_name %> <%= f.text_field :first_name, class: 'form-control' %> </div> <div class="form-group"> <%= f.label :avatar %> <%= f.file_field :avatar, onchange: 'previewFileWithId(preview)', class: 'form-control', accept: 'image/*' %> <%= f.hidden_field :avatar_cache %> </div> <div class='mt-3 mb-3'> <%= image_tag @user.avatar.url, class: 'rounded-circle', id: 'preview', size: '100x100' %> </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>
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(:full_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>
app/views/admin/users/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.decorate.full_name %></td> </tr> <tr> <th scope="row"><%= User.human_attribute_name(:avatar) %></th> <td><%= image_tag @user.avatar.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>
掲示板一覧画面に日付の検索機能を追加
config/initializers/ransack.rb
Ransack.configure do |config| config.add_predicate 'lteq_end_of_day', #設定するpredicateに名前をつける arel_predicate: 'lteq', #使いたいpredicate formatter: proc { |v| v.end_of_day } # ここでend_of_dayメソッドを実行している end
end_of_day: もともとあるメソッドで、1日の終わりを23:59:59にする。
管理画面へのログイン機能、管理画面トップページの作成
AdminLTE version3系をインストール
yarn add admin-lte@^3.1.0
これでnode_modulesとpackage.jsonとyarn.lockというファイルがインストールされ、node_modules/admin-lteディレクトリにデフォルトテンプレートが記載されているので今回はその中のstarter.htmlを使っていく。
管理者ページ用のマニフェストファイルの作成
今までは、app/assets/javascripts/application.js app/assets/stylesheets/application.scssに全て記述していたが、 管理者画面は一般ユーザー用と見た目が大きく異るため、別々で管理していく。
app/assets/javascripts/admin.js
//= require jquery3 //= require rails-ujs //= require admin-lte/plugins/bootstrap/js/bootstrap.bundle.min //= require admin-lte/dist/js/adminlte
app/assets/stylesheets/admin.scss
@import 'admin-lte/plugins/fontawesome-free/css/all.min.css'; @import 'admin-lte/dist/css/adminlte.css';
app/assets/javascripts/application.js
//= require_tree . #削除する
//= require tree.でapplication.js配下の全ファイルを読み込んでいました。 しかし、今回は同じ階層に管理者用のマニフェストファイルをファイルを配置するため、このままだと不必要な管理者用ファイルを読み込んでしまう。
config/initializars/assets.rb
Rails.application.config.assets.precompile += %w[admin.js admin.css]
管理者用コントローラー設定
管理系コントローラーに共通する機能を持つ基底クラス、Admin::BaseControllerを作成する。
他の管理系コントローラーは、Admin::BaseControllerを継承する。
application_controllerを継承するadmin/base_controllerを作成し、全ての管理画面用コントローラーはこのbase_controllerを継承する設計とするには、
rails g controller Admin::Base
app/controllers/admin/base_controller.rb
class Admin::BaseController < ApplicationController before_action :check_admin layout 'admin/layouts/application' private def not_authenticated flash[:warning] = t('defaults.message.require_login') redirect_to admin_login_path end def check_admin redirect_to root_path, warning: t('defaults.message.not_authorized') unless current_user.admin? end end
rails g controller Admin::Dashboards index
app/controllers/admin/dashboards_controller.rb
class Admin::DashboardsController < Admin::BaseController def index; end end
rails g controller Admin::User_sessions new
app/controllers/admin/user_sessions_controller.rb
class Admin::UserSessionsController < Admin::BaseController # BaseControllerを継承 skip_before_action :require_login, only: %i[new create] skip_before_action :check_admin, only: %i[new create] layout 'layouts/admin_login' def new; end def create # Soceryメソッド、emailとpasswordでログイン認証する。 @user = login(params[:email], params[:password]) if @user redirect_to admin_root_path, success: t('.success') else flash.now[:danger] = t('.fail') render :new end end def destroy logout redirect_to admin_login_path, success: t('.success') end end
usersテーブルにroleカラムを追加し、enumを使用して管理者とユーザーを区別
rails g migration add_role_to_users role:integer
class AddRoleToUsers < ActiveRecord::Migration[5.2] def change add_column :users, :role, :integer, null: false, default: 0 end end
rails db:migrate
app/models/user.rbに追加
enum role: { general: 0, admin: 1 }
enumとはモデルの数値カラムに対して文字列の名前を定義することができる。 今回は一般管理者をgeneral、管理者権限をadminとして定義していく。
ルーティング設定
namespace :admin do root to: 'dashboards#index' get 'login', to: 'user_sessions#new' post 'login', to: 'user_sessions#create' delete 'logout', to: 'user_sessions#destroy' end
/admin で始まるURLにしたいので、namespace :adminで名前空間を設定しする。
ビューの設定
app/helpers/application_helper.rb
def page_title(page_title = '', admin = false) base_title = if admin 'RUNTEQ BOARD APP(管理画面)' else 'RUNTEQ BOARD APP' end page_title.empty? ? base_title : page_title + ' | ' + base_title end
管理者用のページは「ダッシュボード | RUNTEQ BOARD APP(管理画面)」のように(管理画面)と出力したい。
app/views/admin/dashboards/index.html.erb
<% content_for(:title, t('.title')) %> <div class="container"> <div class="row"> ダッシュボードです </div> </div>
app/views/admin/layouts/application.html.erb
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta lang='ja'> <meta name="robots" content="noindex, nofollow"> <title><%= page_title(yield(:title), admin: true) %></title> <%= csrf_meta_tags %> <%= stylesheet_link_tag 'admin', media: 'all' %> <link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,400i,700" rel="stylesheet"> </head> <body class="hold-transition sidebar-mini layout-fixed"> <div class="wrapper"> <%= render 'admin/shared/header' %> <%= render 'admin/shared/sidebar' %> <!-- Content Wrapper. Contains page content --> <div class="content-wrapper"> <%= render 'shared/flash_message' %> <%= yield %> </div> <!-- /.content-wrapper --> <%= render 'admin/shared/footer' %> </div> <%= javascript_include_tag 'admin' %> </body> </html>
app/views/admin/shared/_footer.html.erb
<footer class="main-footer"> <strong>Copyright © 2019 RUNTEQ.</strong> All rights reserved. </footer>
app/views/admin/shared/_header.html.erb
<!-- Navbar --> <nav class="main-header navbar navbar-expand navbar-white navbar-light"> <!-- Left navbar links --> <ul class="navbar-nav"> <li class="nav-item"> <a class="nav-link" data-widget="pushmenu" href="#"><i class="fas fa-bars"></i></a> </li> </ul> <!-- Right navbar links --> <ul class="navbar-nav ml-auto"> <li class="nav-item"> <%= link_to t('defaults.logout'), admin_logout_path, class: 'nav-link', method: :delete %> </li> </ul> </nav> <!-- /.navbar -->
app/views/admin/shared/_sidebar.html.erb
<!-- Main Sidebar Container --> <aside class="main-sidebar sidebar-dark-primary elevation-4"> <!-- Brand Logo --> <a href="index3.html" class="brand-link"> <%= image_tag 'AdminLTELogo.png', class: 'brand-image img-circle elevation-3' %> <span class="brand-text font-weight-light">AdminLTE 3</span> </a> <!-- Sidebar --> <div class="sidebar"> <!-- Sidebar user panel (optional) --> <div class="user-panel mt-3 pb-3 mb-3 d-flex"> <div class="image"> <%= image_tag current_user.avatar_url, class: 'img-circle elevation-2' %> </div> <div class="info"> <a href="#" class="d-block"><%= current_user.decorate.full_name %></a> </div> </div> <!-- Sidebar Menu --> <nav class="mt-2"> <ul class="nav nav-pills nav-sidebar flex-column" data-widget="treeview" role="menu" data-accordion="false"> <li class="nav-item"> <%= link_to '#', class: "nav-link" do %> <i class="nav-icon far fa-file"></i> <p> 掲示板 </p> <% end %> </li> <li class="nav-item"> <%= link_to '#', class: "nav-link" do %> <i class="nav-icon far fa-user"></i> <p> ユーザー </p> <% end %> </li> </ul> </nav> <!-- /.sidebar-menu --> </div> <!-- /.sidebar --> </aside>
app/views/admin/user_sessions/new.html.erb
<% content_for(:title, t('.title')) %> <div class="login-box"> <div class="login-logo"> <h1><%= t('.title') %></h1> </div> <!-- /.login-logo --> <div class="card"> <div class="card-body login-card-body"> <%= form_with url: admin_login_path, local: true do |f| %> <%= f.label :email, User.human_attribute_name(:email) %> <div class="input-group mb-3"> <%= f.text_field :email, class: 'form-control', placeholder: :email %> <div class="input-group-append"> <div class="input-group-text"> <span class="fas fa-envelope"></span> </div> </div> </div> <%= f.label :password, User.human_attribute_name(:password) %> <div class="input-group mb-3"> <%= f.password_field :password, class: 'form-control', placeholder: :password %> <div class="input-group-append"> <div class="input-group-text"> <span class="fas fa-lock"></span> </div> </div> </div> <div class="row"> <div class="col-12"> <%= f.submit (t 'defaults.login'), class: 'btn btn-block btn-primary' %> </div> </div> <% end %> </div> </div> </div>
app/views/layouts/admin_login.html.erb
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="robots" content="noindex, nofollow"> <title><%= page_title(yield(:title), admin: true) %></title> <meta name="viewport" content="width=device-width, initial-scale=1"> <%= csrf_meta_tags %> <%= stylesheet_link_tag 'admin', media: 'all' %> </head> <body class="hold-transition login-page"> <div> <%= render 'shared/flash_message' %> <%= yield %> </div> </body> </html>
consoleで権限の変更
rails c [1] pry(main)> User.first.update_attributes(role: :admin) [2] pry(main)> User.first.role 実行結果=> "admin" [3] pry(main)> User.first.admin? 実行結果=> true
参考文献
パスワードリセット機能の実装
sourceryのreset_passwordモジュールの導入
$ rails g sorcery:install reset_password --only-submodules
class SorceryResetPassword < ActiveRecord::Migration[5.2] def change add_column :users, :reset_password_token, :string, default: nil add_column :users, :reset_password_token_expires_at, :datetime, default: nil add_column :users, :reset_password_email_sent_at, :datetime, default: nil add_column :users, :access_count_to_reset_password_page, :integer, default: 0 add_index :users, :reset_password_token end end
rails db:migrate
UserMailerという名前でパスワードリセットメール用のMailerを作成
$ rails g mailer UserMailer reset_password_email
sorcery.rbにreset_passwordサブモジュールを追加し、パスワードリセットに使用するActionMailerとしてUserMailerを定義
config/initializers/sorcery.rb
Rails.application.config.sorcery.submodules = [:reset_password] Rails.application.config.sorcery.configure do |config| config.user_config do |user| user.reset_password_mailer = UserMailer end end
ルーティング、コントローラー、viewファイル作成
・コントローラー
rails g controller PasswordResets new create edit update
app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController skip_before_action :require_login def new; end def create @user = User.find_by(email: params[:email]) @user&.deliver_reset_password_instructions! # 「存在しないメールアドレスです」という旨の文言を表示すると、逆に存在するメールアドレスを特定されてしまうため、 # あえて成功時のメッセージを送信させている redirect_to login_path, success: t('.success') end def edit @token = params[:id] @user = User.load_from_reset_password_token(@token) not_authenticated if @user.blank? end def update @token = params[:id] @user = User.load_from_reset_password_token(@token) return not_authenticated if @user.blank? @user.password_confirmation = params[:user][:password_confirmation] if @user.change_password(params[:user][:password]) redirect_to login_path, success: t('.success') else flash.now[:danger] = t('.fail') render :edit end end end
・ルーティングの設定
Rails.application.routes.draw do mount LetterOpenerWeb::Engine, at: '/letter_opener' if Rails.env.development? resources :password_resets, only: %i[new create edit update] end
・viewファイル作成
app/views/password_resets/edit.html.erb
パスワードリセット申請後、送られてきたメールのURLにアクセスすると、表示される画面の実装
<% content_for(:title, t('.title')) %> <div class="container"> <div class="row"> <div class="col col-md-10 offset-md-1 col-lg-8 offset-lg-2"> <h1><%= t('.title') %></h1> <%= form_with model: @user, url: password_reset_path(@token), local: true do |f| %> <%= render 'shared/error_messages', object: f.object %> <div class="form-group"> <%= f.label :email %> <%= @user.email %> </div> <div class="form-group"> <%= f.label :password %> <%= f.password_field :password, class: 'form-control' %> </div> <div class="form-group"> <%= f.label :password_confirmation %> <%= f.password_field :password_confirmation, class: 'form-control' %> </div> <div class="actions"> <p class="text-center"> <%= f.submit class: 'btn btn-primary' %> </p> </div> <% end %> </div> </div> </div>
app/views/password_resets/new.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 url: password_resets_path, local: true do |f| %> <div class="form-group"> <%= f.label :email, t(User.human_attribute_name(:email)) %><br /> <%= f.email_field :email, class: 'form-control' %> </div> <%= f.submit t('password_resets.new.submit'), class: 'btn btn-primary' %> <% end %> </div> </div> </div>
app/views/user_sessions/new.html.erb
<%= link_to (t '.password_forget'), new_password_reset_path %>
メール文の作成
app/views/user_mailer/reset_password_email.html.erb
<p><%= @user.decorate.full_name %>様</p> <p>===============================================</p> <p>パスワード再発行のご依頼を受け付けました。</p> <p>こちらのリンクからパスワードの再発行を行ってください。</p> <p><a href="<%= @url %>"><%= @url %></a></p>
app/views/user_mailer/reset_password_email.text.erb
<%= @user.decorate.full_name %>様 =============================================== パスワード再発行のご依頼を受け付けました。 こちらのリンクからパスワードの再発行を行ってください。 <%= @url %>
ユーザーにメールを送信するメソッド部分
app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer def reset_password_email(user) @user = User.find(user.id) @url = edit_password_reset_url(@user.reset_password_token) mail(to: user.email, subject: t('defaults.password_reset')) end end
Gemfileにletter_opener_webを追加
group :development do gem 'letter_opener_web', '~> 1.0' end
bundle install
config/environments/development.rbに設定を追加
config.action_mailer.delivery_method = :letter_opener_web config.action_mailer.default_url_options = Settings.default_url_options.to_h
config/environments/test.rb
config.action_mailer.default_url_options = Settings.default_url_options.to_h
環境変数設定
gem 'config'
bundle install
gemの機能で設定ファイルを生成
rails g config:install
カスタマイズ可能な設定ファイルconfig/initializers/config.rbとデフォルト設定ファイルのセットを生成 → config/settings.yml
config/settings/development.yml
config/settings/production.yml
config/settings/test.yml
config/settings/development.yml
default_url_options: host: 'localhost:3000'
config/settings/test.ym
default_url_options: host: 'localhost:3000'
参考文献
プロフィール編集機能の実装
ルーティングの設定
routes.rb
resource :profile, only: %i[show edit update]
今回、プロフィール詳細画面と編集画面へのurlは/profile, /profile/editと 現在ログインしているユーザーの詳細と編集画面だけ表示できればいいので、urlでユーザーのidを参照する必要がない。
コントローラー設定
rails g controller profiles
userの情報は、userモデルに紐づいたデータベースに保存されていますが、今回は新たにモデルに紐づかないprofileコントローラーを生成し、profileコントローラーでプロフィールの編集ページを実装してく。
profile_controller.rb
class ProfilesController < ApplicationController before_action :set_user, only: %i[edit update] def edit; end def update if @user.update(user_params) redirect_to profile_path, 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 show; end private def set_user @user = User.find(current_user.id) end def user_params params.require(:user).permit(:email, :last_name, :first_name, :avatar) end end
アバターカラムの追加
今回は詳細ページにユーザーにアバター画像を追加するのでアバター画像のカラムを用意する カラム名はavatarにするので、下記のコマンドを叩く
rails g uploader Avatar
userのアバター画像のカラムを生成したら、
rails g migration add_avatar_to_users avatar:string
最後に
rails db:migrate
app/uploaders/avatar_uploader.rb
class AvatarUploader < CarrierWave::Uploader::Base storage :file def store_dir "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" end def default_url 'sample.jpg' end def extension_whitelist %w[jpg jpeg gif png] end end
モデルと紐付け
app/models/user.rb
mount_uploader :avatar, AvatarUploader
viewの生成
app/views/profiles/show.html.erb
<% content_for(:title, t('.title')) %> <div class="container pt-3"> <div class="row"> <div class="col-md-10 offset-md-1"> <h1 class="float-left mb-5"><%= t('.title') %></h1> <%= link_to t('defaults.edit'), edit_profile_path, class: 'btn btn-success float-right' %> <table class="table"> <tr> <th scope="row"><%= t(User.human_attribute_name(:email)) %></th> <td><%= current_user.email %></td> </tr> <tr> <th scope="row"><%= t(User.human_attribute_name(:full_name)) %></th> <td><%= current_user.decorate.full_name %></td> </tr> <tr> <th scope="row"><%= t(User.human_attribute_name(:avatar)) %></th> <td><%= image_tag current_user.avatar.url, class: 'rounded-circle', size: '50x50' %></td> </tr> </table> </div> </div> </div>
app/views/profiles/edit.html.erb
<% content_for(:title, t('.title')) %> <div class="container"> <div class="row"> <div class="col-md-10 offset-md-1"> <h1><%= t('.title') %></h1> <%= form_with model: @user, url: profile_path, local: true do |f| %> <%= render 'shared/error_messages', object: f.object %> <%= f.label :email %> <%= f.email_field :email, class: 'form-control mb-3' %> <%= f.label :last_name %> <%= f.text_field :last_name, class: 'form-control mb-3' %> <%= f.label :first_name %> <%= f.text_field :first_name, class: 'form-control mb-3' %> <%= f.label :avatar %> <%= f.file_field :avatar, onchange: 'previewFileWithId(preview)', class: 'form-control mb-3', accept: 'image/*' %> <%= f.hidden_field :avatar_cache %> <div class='mt-3 mb-3'> <%= image_tag @user.avatar.url, class: 'rounded-circle', id: 'preview', size: '100x100' %> </div> <%= f.submit class: 'btn btn-primary' %> <% end %> </div> </div> </div>
ヘッダーアイコン、コメントフォームに設定した画像を表示させる
app/views/shared/_header.html.erb
<%= image_tag current_user.avatar_url, size: '40x40', class: 'rounded-circle mr15'%>
<%= link_to (t 'profiles.show.title'), profile_path, class: 'dropdown-item' %>
app/views/comments/_comment.html.erb
<%= image_tag comment.user.avatar_url, class: 'rounded-circle', size: '50x50' %>
参考文献
掲示板の検索機能を実装
ransackで検索機能を実装する
ransackとは、簡単に検索フォームを作成できるgem
Gemfile
gem 'ransack'
bundle install
コントローラーの修正
今回検索フォームを配置する、掲示板一覧(index)とお気に入り一覧(bookmarks)部分を修正していく。
app/controllers/boards_controller.rb
class BoardsController < ApplicationController def index @q = Board.ransack(params[:q]) @boards = @q.result(distinct: true).includes(:user).page(params[:page]) end def bookmarks @q = current_user.bookmark_boards.ransack(params[:q]) @boards = @q.result(distinct: true).includes(:user).page(params[:page]) end
params[:q]でフォームで検索入力した文字列を取ってくる。
@q.resultで検索結果を渡している。 distinct: trueオプションを使えば、結果の重複を防ぐことができる。
includes 何度もSQLを発行するN+1問題が起こらないように、関連するデータも含めて取得している。
View
それぞれのアクションでは異なるViewファイルを表示しますが、「検索フォーム」は共通
app/views/boards/_search_form.html.erb
<%= search_form_for q, url: url do |f| %> <div class="input-group mb-3"> <%= f.search_field :title_or_body_cont, class: 'form-control', placeholder: '検索ワード' %> <div class="input-group-append"> <%= f.submit '検索', class: 'btn btn-primary' %> </div> </div> <% end %>
ansackのsearch_form_forヘルパーを使用します。
第一引数に、先程定義した検索オブジェクトqを渡します。
urlオプションを設定し、リクエストするUrlを指定する。
今回は指定対象がbookmarks_boards_pathだけだったので、検索フォームからrenderするときにurlを渡しているが、 同じ検索フォームを使い回しし、ページによって検索対象が変わるときには、url: request.path_infoと渡す。
パーシャルファイルの_search_form.html.erbをrenderする各所で、ローカル変数に値を渡す。 app/views/boards/index.html.erb
<%= render 'search_form', q: @q, url: boards_path %>
app/views/boards/bookmarks.html.erb
<%= render 'search_form', q: @q, url: bookmarks_boards_path %>
参考文献
掲示板のページネーション
kaminariのインストール
Gemfile
gem 'kaminari'
bundle install
以下のコマンドを実行すると config/initializers/kaminari_config.rbとページネーションのデフォルトの設定ファイルを生成する。
$ rails g kaminari:config
boardモデルに1ページあたり20件の掲示板を取得するようにboard.rbに paginates_per 20を記載 または、先ほど生成されたconfig/initializers/kamakiri_config.rbに
Kaminari.configure do |config| config.default_per_page = 20 end
kaminariの設定ファイルの説明
# frozen_string_literal: true Kaminari.configure do |config| # config.default_per_page = 25 # config.max_per_page = nil # config.window = 4 # config.outer_window = 0 # config.left = 0 # config.right = 0 # config.page_method_name = :page # config.param_name = :page # config.max_pages = nil # config.params_on_first_page = false end
default_per_page
1ページあたりの表示件数(デフォルトは25レコード)
max_per_page
1ページあたりの最大表示件数(デフォルトはnil。つまり無限)
max_pages
最大ページ数(デフォルトはnil)
window
現在のページから、左右何ページ分のリンクを表示させるか(デフォルトは4件) 上の例では現在は9ページ目で、左右4ページずつ表示させている。
outer_window
最初(First)と最後(Last)のページから、左右何ページ分のリンクを表示させるか(デフォルトは0件)
left
最初(First)のページから、何ページ分のリンクを表示させるか(デフォルトは0件) 上の例では2ページ表示させている(1、2ページ目)。
right
最終(Last)ページから、何ページ分のリンクを表示させるか(デフォルトは0件) 上の例では1ページ表示させている(17ページ目)。
page_method_name
モデルに追加されるページ番号を指定するスコープの名前:page by default
param_name
ページ番号を渡すために使用するパラメータ名(デフォルトは:page) Board.page(params[:page])のようにparamsメソッドで取得できる。
params_on_first_page
false by default
コントローラの修正
app/controllers/boards_controller.rb
class BoardsController < ApplicationController def index @boards = Board.all.includes(:user).order(created_at: :desc).page(params[:page]) end def bookmarks @bookmark_boards = current_user.bookmark_boards.includes(:user).order(created_at: :desc).page(params[:page]) end end
board_controllerで、掲示板の一覧とブックマークの一覧を取得処理でpageを使用する。
page params[:page]を記載 ページネーションのビューはbootstrap4のレイアウトを適用するために 以下のコマンドを実行
bundle exec rails g kaminari:views bootstrap4
app/views/kaminari/以下にページネーションの指定したレイアウトを適用したビューファイルを生成してくれる。
ページネーションの表示
掲示板一覧とブックマーク一覧にpaginateを追加
app/views/boards/bookmarks.html.erb
<%= paginate @bookmark_boards %>
app/views/boards/index.html.erb
<%= paginate @boards %>
参考文献
コメント投稿、削除、編集機能のajax化
コメント投稿、削除処理のajax
form_withのlocal: trueを削除して非同期処理にする。
app/views/comments/_form.html.erb
<div class="row mb-3"> <div class="col-lg-8 offset-lg-2"> <%= form_with model: comment, url: [board, comment], id: 'new_comment' do |f| %> <%= f.label :body %> <%= f.text_area :body, class: 'form-control mb-3', id: 'js-new-comment-body', row: 4, placeholder: Comment.human_attribute_name(:body) %> <%= f.submit t('defaults.post'), class: 'btn btn-primary' %> <% end %> </div> </div>
コメントの削除ボタンlink_toにremote: trueを追加 app/views/comments/_comment.html.erb
<%= link_to comment_path(comment), class: 'js-delete-comment-button', method: :delete, data: { confirm: t('defaults.message.delete_confirm') }, remote: true do %> <%= icon 'fa', 'trash' %> <% end %>
コメント作成、削除時の動的レンダリング処理を追加
コメントの追加に成功した場合、追加したコメントをコメント一覧に追加する処理をjavascriptで実装する。
また、コメントの追加に失敗した場合は、エラーメッセージを表示するようにする。
app/views/comments/create.js.erb
$("#error_messages").remove() <% if @comment.errors.present? %> $("#new_comment").prepend("<%= j(render('shared/error_messages', object: @comment)) %>") <% else %> $("#js-table-comment").prepend("<%= j(render('comments/comment', comment: @comment)) %>") $("#js-new-comment-body").val('') <% end %>
コメントの追加と同様に、コメントの削除した時にコメントリストから対象のコメントを取り除く。
app/views/comments/destroy.js.erb
$("tr#comment-<%= @comment.id %>").remove()
コメント編集ajax化
app/assets/javascripts/edit_comment.js
$(function() { $(document).on("click", '.js-edit-comment-button', function(e) { e.preventDefault(); const commentId = $(this).data("comment-id") switchToEdit(commentId) }) $(document).on("click", '.js-button-edit-comment-cancel', function() { clearErrorMessages() const commentId = $(this).data("comment-id") switchToLabel(commentId) }) $(document).on("click", '.js-button-comment-update', function() { clearErrorMessages() const commentId = $(this).data("comment-id") submitComment($("#js-textarea-comment-" + commentId).val(), commentId) .then(result => { $("#js-comment-" + result.comment.id).html(result.comment.body.replace(/\r?\n/g, '<br>')) switchToLabel(result.comment.id) }) .catch(result => { const commentId = result.responseJSON.comment.id const messages = result.responseJSON.errors.messages showErrorMessages(commentId, messages) }) }) function switchToLabel(commentId) { $("#js-textarea-comment-box-" + commentId).hide() $("#js-comment-" + commentId).show() } function switchToEdit(commentId) { $("#js-comment-" + commentId).hide() $("#js-textarea-comment-box-" + commentId).show() } function showErrorMessages(commentId, messages) { $('<p class="error_messages text-danger">' + messages.join('<br>') + '</p>').insertBefore($("#js-textarea-comment-" + commentId)) } function submitComment(body, commentId) { return new Promise(function(resolve, reject) { $.ajax({ type: 'PATCH', url: '/comments/' + commentId, data: { comment: { body: body } } }).done(function (result) { resolve(result) }).fail(function (result) { reject(result) }); }) } function clearErrorMessages() { $("p.error_messages").remove() } });
コメントコントローラを修正
コメント作成時のredirect_backを削除し、コメント削除処理を追加
app/controllers/comments_controller.rb
class CommentsController < ApplicationController def create @comment = current_user.comments.build(comment_params) @comment.save end def destroy @comment = current_user.comments.find(params[:id]) @comment.destroy! end
コメント削除のルートを追加 config/routes.rb
Rails.application.routes.draw do resources :boards do resources :comments, only: %i[create destroy], shallow: true collection do get :bookmarks end end end
参考文献