AdminLTEで管理画面の実装

AdminLTEを使用し、以下のような管理画面トップページと管理画面へのログイン機能を実装する。 f:id:meo2:20210703181246p:plain

AdminLTEとは

Bootstrapをベースにした、管理画面等のCSSフレームワーク
今回はバージョン3を使用

AdminLTEのインストール

 $ yarn add admin-lte@^3.0

node_modulesディレクトリが作成され、ここにテンプレートが入っている。

マニフェストファイルの設定

マニフェストファイルとは、ロードするcssやjsファイルを記述しておく場所。
マニフェストファイルはapp/assets/javascriptsapp/assets/stylesheetsにある。
adminページ用のマニフェストファイルを作成

 //= require jquery3
//= require jquery_ujs
//= require admin-lte/plugins/bootstrap/js/bootstrap.bundle.min
//= require admin-lte/dist/js/adminlte.min
@import "font-awesome-sprockets";
@import "font-awesome";
@import 'admin-lte/plugins/fontawesome-free/css/all.min.css';
@import 'admin-lte/dist/css/adminlte.min.css';

application.jsの設定

今回はadmin用マニフェストファイルを同じディレクトリに作成するので、任意のファイルだけ読み込む必要がある。
require = tree.があるとapplication.js配下のファイルをすべて読み込んでしまうので、削除して個別にファイルを指定する。

//= require jquery3
//= require popper
//= require bootstrap-sprockets
//= require rails-ujs
//= require activestorage
//= require cable.js

アセットのプリコンパイル設定

アセットとはスタイルシートJavaScriptなどのリソース
application.js以外のファイルを読み込む場合はプリコンパイルの設定をする必要がある。
以下のファイルのコメントアウトを外す。

Rails.application.config.assets.precompile += %w( admin.js admin.css )

これで、admin.jsとadmin.cssが認識されるようになる。(プリコンパイル)

usersテーブルに権限を判定するカラムを追加

ユーザーとadminを判別するためにroleカラムを追加する。
データ型はinteger(整数)とし、ユーザー権限を0、admin権限を1とする。

$ rails g migration add_role_to_users 
class AddRoleToUsers < ActiveRecord::Migration[5.2]
  def change
    add_column :users, :role, :integer, default: 0, null: false
  #デフォルトを0とし一般ユーザとする
  end
end

マイグレートする。

== 20210702210922 AddRoleToUsers: migrating ===================================
-- add_column(:users, :role, :integer, {:default=>0, :null=>false})
   -> 0.0013s
== 20210702210922 AddRoleToUsers: migrated (0.0016s) ==========================

enumの追加

enumは、先程定義したモデルのintegerカラムに対して文字列の名前をつけることができる。

  • general・・・一般
  • admin・・・管理者 と定義する。
   enum role: { general: 0, admin: 1 }

current_user.admin?などが使えるようになる。

ルーティング

ルーティングの指定にはnamespaceを使う。
/adminから始まるURLにすることができる。

  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

f:id:meo2:20210703181340p:plain

controller

管理画面用のコントローラの基盤を作成。
application_controllerを継承するadmin/base_controllerを作成する。
他の全ての管理画面用コントローラーはこのbase_controllerを継承する。

% rails g controller admin::base

::ディレクトリの階層を指定できる。

 class Admin::BaseController < ApplicationController
  before_action :check_admin
  layout 'admin/layouts/application'
  
  private
  
  def not_authenticated
    flash[:warning] = 'ログインしてください'
    redirect_to admin_login_path
  end
  
  def check_admin
    redirect_to root_path, warning: '権限がありません' unless current_user.admin?
  end
end

check_adminメソッドで管理者以外はトップページへ遷移させる。 さらに、管理画面用のトップページに遷移するコントローラーadmin/dashboards_controller.rb と管理画面ログイン用のadmin/user_sessions_controllerを作成する。
これらは全てAdmin::BaseControllerを継承する。

$ rails g controller admin::user_session
$ rails g controller admin::dashboards
class Admin::UserSessionController < Admin::BaseController
  skip_before_action :check_admin, only: %i[new create]
  skip_before_action :require_login, only: %i[new create]
  layout 'admin/layouts/admin_login'
  
  def new; end
  
  def create
    @user = login(params[:email], params[:password])
    if @user
      redirect_to admin_root_path, success: 'ログインしました'
    else
      flash.now[:danger] = 'ログインに失敗しました'
      render :new
    end
  end
  
  def destroy
    logout
    redirect_to admin_login_path, success: 'ログアウトしました'
  end
end

ログイン画面はskip_before_action :require_loginでログインしなくてもアクセスできるようにする。
ログイン画面で読み込むレイアウトファイルはadmin_login.html.erbを指定。

class Admin::DashboardsController < Admin::BaseController
  def index; end
end

Admin::BaseControllerを継承しているのでレイアウトは定義しなくていい。

ログイン画面のview

adminログイン画面の作成
/admin/loginへ以下のようなログイン画面を作成。
node_modules/admin-lte/pages/example/login.htmlのテンプレートを参考に作成する。
f:id:meo2:20210703181414p:plain:w300:h300

  <!DOCTYPE html>
<html>
 <head>
   <meta charset="utf-8">
   <meta http-equiv="X-UA-Compatible" content="IE=edge">
   <meta name="robots" content="noindex, nofollow">
   <title>AdminLTE 3 | Log in</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>
  <% content_for(:title, 'ログイン') %>
 <div class="login-box">
   <div class="login-logo">
     <h1>ログイン</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, 'メールアドレス' %>
         <div class="input-group mb-3">
           <%= f.text_field :email, class: 'form-control', placeholder: 'メールアドレス' %>
           <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>

ログインフォームはモデルと紐付かないので、form_withのオプションはurl:を使用
フォーム送信先/admin/loginなのでadmin_login_pathとする。
何もないとajax通信になるのでlocal: trueをつける。

ユーザに管理者権限を付与する。

実際にログインしたりするには管理者権限がついたユーザを作る必要がある。
今回はID=1のユーザに管理者権限を設定する。

 pry(main)> user = User.find(1)
  User Load (0.1ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<User:0x00007f905aa97c60
 id: 1,
 email: "meo@gmail.com",
 crypted_password: "$2a$10$sdmyh3nhvkXVVIWTA5IVA.b4Pc6Syqz5/UCuBFCOMUg/fPVSMAxAa",
 salt: "56AQgaWqsDrNWaDTq7Kw",
 created_at: Sun, 30 May 2021 16:45:18 JST +09:00,
 updated_at: Wed, 23 Jun 2021 16:22:28 JST +09:00,
 last_name: "meoq",
 first_name: "meoq",
 avatar: "883957.png",
 reset_password_token: "Fzo_q2jdTB3qbxQqsxJm",
 reset_password_token_expires_at: nil,
 reset_password_email_sent_at: Sun, 27 Jun 2021 19:59:50 JST +09:00,
 access_count_to_reset_password_page: 0,
 role: "general">

 pry(main)> user.admin!
   (0.1ms)  begin transaction
  User Exists (0.3ms)  SELECT  1 AS one FROM "users" WHERE "users"."email" = ? AND "users"."id" != ? LIMIT ?  [["email", "meo@gmail.com"], ["id", 1], ["LIMIT", 1]]
  User Exists (1.3ms)  SELECT  1 AS one FROM "users" WHERE "users"."reset_password_token" = ? AND "users"."id" != ? LIMIT ?  [["reset_password_token", "Fzo_q2jdTB3qbxQqsxJm"], ["id", 1], ["LIMIT", 1]]
  User Update (0.5ms)  UPDATE "users" SET "updated_at" = ?, "role" = ? WHERE "users"."id" = ?  [["updated_at", "2021-07-03 17:48:59.494861"], ["role", 1], ["id", 1]]
   (1.3ms)  commit transaction
=> true

 pry(main)> user.save
   (1.7ms)  begin transaction
  User Exists (0.2ms)  SELECT  1 AS one FROM "users" WHERE "users"."email" = ? AND "users"."id" != ? LIMIT ?  [["email", "meo@gmail.com"], ["id", 1], ["LIMIT", 1]]
  User Exists (0.1ms)  SELECT  1 AS one FROM "users" WHERE "users"."reset_password_token" = ? AND "users"."id" != ? LIMIT ?  [["reset_password_token", "Fzo_q2jdTB3qbxQqsxJm"], ["id", 1], ["LIMIT", 1]]
   (0.1ms)  commit transaction
=> true

管理者画面のview

以下のような管理者画面共通のviewの作成。 f:id:meo2:20210703181246p:plain
管理者用テンプレートの作成

 <!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <meta lang='ja'>
    <meta name="robots" content="noindex, nofollow">
    <title>ダッシュボード | (管理画面)</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    <%= 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> 

views/admin/sharedのheader/sidebar/footerのパーシャルを読み込む。
<%= stylesheet_link_tag 'admin', media: 'all' %>
<%= javascript_include_tag 'admin' %>この記述でブラウザにアセットを読み込んでいる。

ダッシュボードのview

<div class="content-wrapper">
  <div class="row">
   <p>ダッシュボードです</p>
  </div>
</div>

パスワードリセット機能のバグ修正

エラー内容

rspec実行時におけるエラー
f:id:meo2:20210630234619p:plain ②リンクからパスワードリセットページへ行き、新しいパスワードを入力して更新を押すが更新できない。

解決手順①

Unable to find field "メールアドレス" that is not disabledから実際のhtmlのfieldとテストコードのfieldが異なっていることがわかる。
該当のテストコード
fill_in 'メールアドレス', with: user.email
デベロッパーツールでhtmlを調べるとname属性がemailとなっているのでエラーになっている。 f:id:meo2:20210701013337p:plain

fill_in 'email', with: user.emailとすればテストが通るようになるが、今回はview側を修正する。

form.labelの書き方を修正。

  <% content_for(:title, t('.password')) %>
<h1>パスワードリセット申請</h1>
<%= form_with url: password_resets_path, local: true , method: :post do |form| %>
  <%= form.label :email %><br>
  <%= form.text_field :email, class: 'form-control' %><br>
  <%= form.submit '送信', class: 'btn btn-primary' %>
<% end %>

<%= form.label :email %>から
<%= form.label :email, "メールアドレス" %>へ変更すれば、テストコードを変更しなくてもテストが通るようになる。

解決手順②

②は結論から言うと、userモデルにallow_nil: trueを定義していなかった。

  validates :reset_password_token, uniqueness: true, allow_nil: true

reset_password_tokenはパスワードを変更した際、nilになるので一位制約に引っかかる。
allow_nil: truenilを許可する。
実際のサーバログ
[["reset_password_token", nil]とでている。 f:id:meo2:20210701021147p:plain

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

概要

  • パスワードリセット申請画面にメールアドレスを入力し申請後、tokenを発行しデータベースへ保存させる
  • 入力されたメールアドレスにパスワードリセットページヘのリンクを送信し、発行したtokenをURLへ組み込み、ユーザーを判別する。
  • ユーザーはリンクからパスワードリセットページへ行き、新しいパスワードを入力し更新できる。

実装

sorceryのreset_passwordモジュールを導入する。

 rails g sorcery:install reset_password --only-submodules
        gsub  config/initializers/sorcery.rb
      insert  app/models/user.rb
      create  db/migrate/20210625080320_sorcery_reset_password.rb

マイグレーションファイルのテーブル名を編集し、migrateする。

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
== 20210625080320 SorceryResetPassword: migrating =============================
-- add_column(:users, :reset_password_token, :string, {:default=>nil})
   -> 0.0012s
-- add_column(:users, :reset_password_token_expires_at, :datetime, {:default=>nil})
   -> 0.0012s
-- add_column(:users, :reset_password_email_sent_at, :datetime, {:default=>nil})
   -> 0.0008s
-- add_column(:users, :access_count_to_reset_password_page, :integer, {:default=>0})
   -> 0.0010s
-- add_index(:users, :reset_password_token)
   -> 0.0015s
== 20210625080320 SorceryResetPassword: migrated (0.0065s) ====================

Userモデルにallow_nil: trueとユニーク制約をつける。

validates :reset_password_token, uniqueness: true, allow_nil: true

Action Mailerの作成

RailsではデフォルトでActionMailerというメール送信機能がある。
パスワードリセットに使用するUserMailerを作成。

$ rails g mailer UserMailer reset_password_email
Running via Spring preloader in process 5906
      create  app/mailers/user_mailer.rb
      invoke  erb
      create    app/views/user_mailer
      create    app/views/user_mailer/reset_password_email.text.erb
      create    app/views/user_mailer/reset_password_email.html.erb

サブモジュールを使用できるように定義を追加。
sorcery.rbのリセットパスワードメイラーにUserMailerを記述。
これでapp/mailers/user_mailer.rbとつながる。

  Rails.application.config.sorcery.submodules = [:reset_password]

# Here you can configure each submodule's features.
Rails.application.config.sorcery.configure do |config|
  config.user_config do |user|
    user.reset_password_mailer = UserMailer 
  end

user_mailer.rbへパスワードリセット用のメソッドを記述。

class UserMailer < ApplicationMailer
  def reset_password_email
     @user = User.find user.id
     @url = edit_password_reset_url(@user.reset_password_token)
          mail(to: user.email, 
          subject: 'パスワードリセット') 
  end

@user = User.find user.idでユーザー情報を取得
reset_password_emailメソッドでuser_mailer/reset_password_email.html.erbがメールのフォーマットとして読み込まれる。
@url = edit_password_reset_url(@user.reset_password_token) mail(to: user.email,パスワード変更のurlと送信先アドレスを指定。

メールの差し出し元とレイアウトファイルを指定

app/views/layoutsのmailerファイルがレイアウトになる。

  class ApplicationMailer < ActionMailer::Base
  default from: 'from@example.com'
  layout 'mailer'
end

メイラーのview設定

実際に送られてくるメール本文

  <h1>パスワードリセット</h1>
<p>
  <%= @user.last_name %><%= @user.first_name %> 様
  パスワード再発行のご依頼を受け付けました。
  こちらのリンクからパスワードの再発行を行ってください。
  <%= @url %>
</p>
  <%= @user.last_name %><%= @user.first_name %> 様
パスワード再発行のご依頼を受け付けました。
こちらのリンクからパスワードの再発行を行ってください。
<%= @url %>

letter_opener_webの追加

開発環境ではメールは送られないように設定する。
gemfileのdevelopmentに記載し、bundleする。

  group :development do
  gem 'letter_opener_web', '~> 1.0'
end

ルーティングの下へletter_opener_webにアクセスできる記述を追加。

mount LetterOpenerWeb::Engine, at: '/letter_opener' if Rails.env.development?

development.rbへ設定を追加。

Rails.application.configure do
  config.action_mailer.perform_caching = false

  config.action_mailer.default_url_options = { host: 'localhost:3000' }
  config.action_mailer.delivery_method = :letter_opener_web
end

letter_openerでメールが送られるか確認。

f:id:meo2:20210630003157p:plain

環境変数の設定

gem configの導入
Gemfileへgem 'config'を記述。bundle後、以下のコマンドを実行。

$ rails g config:install

設定ファイルが生成される。

  • config/settings/development.yml
  • config/settings/production.yml
  • config/settings/test.yml
    定数の置き換え(すべてのファイルに定数を記述する。)
    今回はlocalhost:3000(本番環境にデプロイする時はproduction.ymlへ取得したドメインを記述する。)
default_url_options:
  host: 'localhost:3000'

Settings.hostで置き換える

  config.action_mailer.perform_caching = false
  config.action_mailer.default_url_options = { host: Settings.host }
  config.action_mailer.delivery_method = :letter_opener_web

ルーティングの追加

  resources :password_ressets, only: %i[new create edit update]

f:id:meo2:20210629163315p:plain

controller

  class PasswordResetsController < ApplicationController
  skip_before_action :require_login
   #パスワードリセット申請フォームのアクション
  def new; end
   #パスワードリセット申請フォームでemailを入力し、送信した時に実行。
  def create
    @user = User.find_by(email: params[:email])
   #form_withから送られたemailを受け取る。
    @user&.deliver_reset_password_instructions!
   #DBからデータを受け取っている場合、ユーザにトークン付きURLをメールで送信する。
    redirect_to login_path, success: 'パスワードリセット手順を送信しました'
  end
  #メールに送信されたtokenURLからパスワードリセットページへ遷移するアクション
  def edit
    @token = params[:id]
    #postされてきた値を取得
    @user = User.load_from_reset_password_token(@token)
    #リクエストで送信されてきたトークンを使って、ユーザーの検索を行う。
    #トークンが見つかり、有効であればそのユーザーオブジェクトを@userに格納。
    not_authenticated if @user.blank?
    #@userがnilまたは空の場合、not_authenticatedメソッドを実行する
  end
 #ユーザがパスワードリセットページで新しいパスワードを入力し、送信したときに実行。
  def update
    @token = params[:id]
    @user = User.load_from_reset_password_token(params[:id])
    return not_authenticated if @user.blank?

    @user.password_confirmation = params[:user][:password_confirmation]
    #password_confirmation属性の有効性を確認
    if @user.change_password(params[:user][:password])
    #change_passwordメソッドで、パスワードリセットに使用したトークンを削除し、パスワードを更新する 
       redirect_to login_path, success: 'パスワードを変更しました'
    else
       render 'edit'
    end
  end
end

パスワードリセット申請ページの作成

ユーザがパスワードをリセットしたいときにemailを入力し送信する画面。
f:id:meo2:20210630014249p:plain:w250:h100

view

  <% content_for(:title, t('.password')) %>
<h1>パスワードリセット申請</h1>
<%= form_with url: password_resets_path, local: true , method: :post do |form| %>
  <%= form.label :email, "メールアドレス" %><br>
  <%= form.text_field :email, class: 'form-control' %><br>
  <%= form.submit '送信', class: 'btn btn-primary' %>
<% end %>

パスワードリセット画面の作成

パスワードリセット画面はパスワード初期化メールに記載されたURLから行くページ。
f:id:meo2:20210630013941p:plain:w300:h200

view

  <% content_for(:title) { 'パスワードリセット' } %>
<h1>パスワードリセット</h1>
<%= form_with model: @user, url: password_reset_path(@token), local: true, method: :put do |f| %>
  <%= f.label :email %><br>
  <%= @user.email %><br>

  <%= f.label :password %><br>
  <%= f.password_field :password, class: 'form-control' %>

  <%= f.label :password_confirmation %><br>
  <%= f.password_field :password_confirmation, class: 'form-control' %>

  <%= f.submit '更新する', class: 'btn btn-primary' %>
<% end %>

プロフィール編集画面

実装内容

プロフィール詳細ページ(/profile)と編集ページ(/profile/edit)を作成する
プロフィール編集画面からアバター画像をアップロードできるようにし、プロフィール詳細画面、ヘッダーアイコン、コメントしたユーザーのアイコン画像が表示されるようにする。

画像のカラムを作成
usersテーブルにavatarカラムを追加

$ rails g migration add_avatar_to_users avatar:string
$ rails db:migrate
== 20210623060835 AddAvatarToUsers: migrating =================================
-- add_column(:users, :avatar, :string)
   -> 0.0014s
== 20210623060835 AddAvatarToUsers: migrated (0.0017s) ========================

アップローダーの作成→参考

$ rails g uploader avatar  
create  app/uploaders/avatar_uploader.rb

モデルと紐付け

mount_uploader :avatar, ImageUploader
  

uploader.rbの編集

   include CarrierWave::MiniMagick
  def store_dir
    "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
  end

  def default_url(*args)
   'sample.jpg'
  end

  def extension_allowlist
    %w(jpg jpeg gif png)
  end
  

ルーティング

resource :profile, only: %i[show edit update]
  

resourcesとresourceメソッド

プロフィールはユーザーに対して1つしか存在しなく、idを表示させると他のユーザーのプロフィールを編集できてしまうので表示しないように設定。
resourcesではなくresourceを使う。
resourceを使うことでid付きでURLが生成されなくなる。

resourceの場合

edit_profile GET /profile/edit(.:format) profiles#edit
profile GET /profile(.:format) profiles#show
PATCH /profile(.:format) profiles#update
PUT /profile(.:format) profiles#update

resourcesの場合

edit_profile GET /profile/:id/edit(.:format) profile#edit
profile GET /profile/:id(.:format) profile#show
PATCH /profile/:id(.:format) profile#update
PUT /profile/:id(.:format) profile#update

controller

profilesコントローラーの作成

$ rails g controller profiles

ストロングパラメータにavatarを追加。

class ProfilesController < ApplicationController
  def show; end

  def edit
    @user = User.find(current_user.id)
  end

  def update
    @user = User.find(current_user.id)
    if @user.update(user_params)
      redirect_to profile_path, success: 'ユーザーを更新しました'
    else
      flash.now['danger'] = 'ユーザーを更新できませんでした'
      render :edit
    end
  end

  private

  def user_params
    params.require(:user).permit(:email, :first_name, :last_name, :avatar)
  end
end
  

view

プロフィール詳細、編集画面のviewを作成

<% 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 '編集', '/profile/edit', class: 'btn btn-success float-right' %>
      <table class="table">
        <tr>
          <th scope="row">メールアドレス</th>
          <td><%= current_user.email %></td>
        </tr>
        <tr>
          <th scope="row">氏名</th>
          <td><%= current_user.decorate.full_name %></td>
        </tr>
        <tr>
          <th scope="row">アバター</th>
          <td><%= image_tag current_user.avatar.url, class: 'rounded-circle mr15', size: '50x50' %></td>
        </tr>
      </table>
    </div>
  </div>
</div>
  
<% content_for(:title, t('.title')) %>
<div class="container pt-3">
  <div class="row">
    <div class="col-md-10 offset-md-1 mb-5">
      <h1 class="float-left"><%= t('.title')%></h1>
    </div>
    <div class="col-md-10 offset-md-1">
      <%= form_with model: @user, url: profile_path, 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, class: "form-control", accept: 'image/*', onchange: 'previewFileWithId(preview)' %>
          <%= f.hidden_field :avatar_cache %>
          <div class='mt-3 mb-3'>
            <%= image_tag @user.avatar.url , size: '100x100', class: 'rounded-circle' %>
          </div>
        </div>
        <%= f.submit class: "btn btn-primary"%>
      <% end %>  
    </div>
  </div>
</div>
  

ヘッダーアイコン、コメントフォームに設定した画像を表示させる

ヘッダーはログイン中のユーザー

<%= image_tag current_user.avatar_url, size: '40x40', class: 'rounded-circle mr15'%>
  

コメントフォームはコメントしたユーザーのアイコンを表示

<%= image_tag comment.user.avatar_url, class: 'rounded-circle', size: '50x50' %>
  

i18n追記

  profiles:
     edit:
      title: 'プロフィール編集'
     show:
      title: 'プロフィール'
      email: 'メールアドレス'
      name: '氏名'
      avatar: 'アバター'

gem ransackを使用し検索機能の実装

実装内容

gem 'ransack'を使用し、掲示板一覧画面とブックマーク一覧画面に以下のようなフォームを設置する。
f:id:meo2:20210622165004p:plain:w500:h40

  • 入力された文言が「掲示板のtitle,body」に含まれている掲示板のみを表示させる。
  • ブックマーク一覧のページで検索した場合は「ブックマークした掲示板の中から」検索条件に合致したものを表示させる。

  • 掲示板一覧画面での検索はboards#indexアクション、

  • ブックマーク一覧画面での検索はboards#bookmarksアクションで行う。

導入

gem 'ransack'
  

bundle install

controller

掲示板一覧(index)アクションとブックマーク一覧(bookmarks)アクションを修正

~
    def index
    if logged_in?
      @q = Board.ransack(params[:q])
      @boards = @q.result(distinct: true).includes(:user).order(created_at: :desc).page(params[:page])
    else
      redirect_to login_path, danger: 'ログインしてください'
    end
  end

    def bookmarks
      @q = current_user.bookmark_boards.ransack(params[:q])
      @bookmark_boards = @q.result(distinct: true).includes(:user).order(created_at: :desc).page(params[:page])
    end
~

params[:q]で検索フォームに入力した文字を取得(viewから送られてきたパラメータ)デフォルトで:qとなっている。→インスタンス変数@qを定義。
ransackメソッド、送られてきたパラメータをもとにテーブルからデータを検索。
resultメソッドでransackメソッドで取得したデータをActiveRecord_Relationのオブジェクトに変換し、検索結果を渡す。
(distinct: true)で重複を防ぐ

includesメソッドでN+1問題の回避。
orderメソッドで並び替え。
pageメソッドでページネーションの実装。

view

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

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

search_form_forメソッドはransackで用意されているメソッド
form_withやform_forと同じ
検索オブジェクトqを指定。
urlオプションでリクエストするurlを渡す。→renderでパーシャルを読み込む時に指定。

f.search_fieldで入力フォームを作成すると検索フォームとして作成される。
今までは文字の場合→f.text_fieldで記述していた。
f.search_fieldを指定することで、inputタグにtype="search"が記述され、フォームの右に「x」ボタンが表示されるようになる。
:title_or_body_contで検索対象のカラムを指定。今回は2つあるのでorで区切る。
f:id:meo2:20210622182234p:plain

パーシャルを読み込む

掲示板一覧画面とブックマーク一覧画面にパーシャルをレンダーさせる。
ローカル変数qに値を渡す。
pathは各アクションのやつを指定。

<!-- 検索フォーム -->
<form>
  <%= render 'boards/search_form', url: boards_path, q: @q %>
</form>
<!-- 検索フォーム --><form>
  <%= render 'boards/search_form', url: bookmarks_boards_path, q: @q %>
</form>

gem kaminariを使用しページネーションを実装

ページネーション

ページネーションとは検索結果や内容の多いページを分割して表示させ、見やすくすること
使用例↓
f:id:meo2:20210620183825p:plain:w300:h100

実装内容

掲示板一覧画面とブックマーク一覧画面にページネーションを実装。
ページネーションにはkaminariを使用し、1ページあたり20件の掲示板を表示させる。
bootstrap4で形成。

導入

gem 'kaminari'のインストール

  gem 'kaminari'

bundle install

ページネーション設定ファイルの作成

$ rails g kaminari:config
Running via Spring preloader in process 2147
      create  config/initializers/kaminari_config.rb

生成されたファイルを編集。

  # frozen_string_literal: true

Kaminari.configure do |config|
  config.default_per_page = 20
  # 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

コメントアウトを外して使用する。
今回は1ページに20件の掲示板を表示させたいので20と指定する。
反映にはサーバーの再起動が必要。

bootstrapを適応

% rails g kaminari:views bootstrap4

このコマンドを実行するだけでページネーションに自動でbootstrapが適応される。

controller

pageメソッドが使用可能になっているので掲示板一覧とブックマーク一覧画面のアクションへ追記。

~
  def index
    if logged_in?
      @boards = Board.all.includes(:user).order(created_at: :desc).page(params[:page])
    else
      redirect_to login_path, danger: 'ログインしてください'
    end
  end

    def bookmarks
    @bookmark_boards = current_user.bookmark_boards.includes(:user).order(created_at: :desc).page(params[:page])
  end
~

view

掲示板一覧、ブックマーク一覧のviewへpagenateヘルパーのを記述。
paginateでコントローラの@boards@bookmark_boardsを呼び出す。

~
  <%= paginate @boards %>
~
~
<%= paginate @bookmark_boards %>
~  

完成↓
f:id:meo2:20210620192323p:plain:w450:h250

コメント投稿、削除のAjax化

実装内容

コメントの投稿、削除処理をajax(remote :true)で行う。
削除ボタンのaタグにはjs-delete-comment-buttonclassを付与。

ルーティング

commentsに削除を追加。

~
    resources :users
  resources :boards do
    resources :comments, only: %i[create destroy], shallow: true
    collection do
      get :bookmarks
    end
  end
  resources :bookmarks, only: %i[create destroy]
end

f:id:meo2:20210620195252p:plain

コメント投稿、削除のAjax

コメント投稿フォームでの処理をajax化するので、form_withにlocal:trueremote:trueへ変更。
form_withはデフォルトでremote:trueなので書かなくても良い

  <!-- コメントフォーム -->
<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| %>
      <%= render 'shared/error_messages', object: f.object %>    
      <%= f.label :body %>
      <%= f.text_area :body, class: 'form-control mb-3', id: 'js-new-comment-body', placeholder: 'コメント' %>
      <%= f.submit '投稿', class: 'btn btn-primary' %>
    <% end %>
  </div>
</div>   

コメント削除ボタンのlink_toへremote:trueを追記
リンク先のpathを指定。

~
  <% if current_user.own?(comment) %>  <!--自分のコメントだけ編集アイコンを表示する-->
     <td class="action">
       <ul class="list-inline justify-content-center" style="float: right;">
         <li class="list-inline-item">
           <a href="#" class='js-edit-comment-button' data-comment-id="<%= comment.id %>">
             <%= icon 'fa', 'pen' %>
           </a>
         </li>
         <li class="list-inline-item">
           <%= link_to comment_path(comment), method: :delete, remote: true, class: 'js-delete-comment-button' ,data: {confirm: 'よろしいですか'} do %>
             <%= icon 'fas', 'trash'%>
           <% end %>     
           </a>
         </li>
        </ul>
      </td>
    <% end %>
</tr>

comments_controllerの修正

redirect_backを削除

~
    def create
    @comment = current_user.comments.build(comment_params)
    @comment.save
  end

  def destroy
    @comment = current_user.comments.find(params[:id])
    @comment.destroy!
  end
~

jsファイルの作成

  $("#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 %>
  $("#comment-<%= @comment.id %>").remove();