Ryota400’s blog

エンジニアを目指して書いてます。

管理画面 掲示板/ユーザの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 &copy; 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

参考文献

qiita.com

kossy-web-engineer.hatenablog.com

miiina01220.hatenablog.com

パスワードリセット機能の実装

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'

参考文献

github.com

プロフィール編集機能の実装

ルーティングの設定

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' %>

参考文献

qiita.com

qiita.com

掲示板の検索機能を実装

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 %>

参考文献

qiita.com

掲示板のページネーション

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 %>

参考文献

qiita.com

github.com

qiita.com

コメント投稿、削除、編集機能の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

参考文献

qiita.com

yoshi14m3185.hatenablog.com