Chapter 09

商品に関する機能を実装しよう(管理者)

FarStep
FarStep
2023.01.08に更新

はじめに

本 Chapter では、EC サイトで扱う商品に関する実装を行います。
ただし、本 Chapter で実装するのは 管理者側 のものです。
したがって、商品を作成・閲覧・編集する機能を作成していきます。

モデル・テーブルを作成しよう

最初に Product モデル・products テーブルの作成を行います。
コンテナが立ち上がっていることが確認できましたら、ecommerce_web コンテナに入ります。

$ docker-compose run --rm web bash
Creating ecommerce_web_run ... done
ruby@884097f5536a:/app$

無事コンテナに入ることができましたら、下記コマンドを実行してください。

$ rails g model Product name description:text price:integer stock:integer

上記コマンドを実行するとマイグレーションファイルが生成されるはずです。
db/migrate/xxxxxxxxxxxxxx_create_products.rb を開いて下記コードを記述してください。
(x には数字が入ります)

class CreateProducts < ActiveRecord::Migration[7.0]
  def change
    create_table :products do |t|
      t.string :name, null: false
      t.text :description, null: false
      t.integer :price, null: false
      t.integer :stock, null: false, default: 0

      t.timestamps
    end
  end
end

これで、products テーブルのカラムは全て null: false 制約がかけられます。
また、stock カラムの初期値は 0 としました。

マイグレーションファイルの編集が完了したら、下記コマンドを実行して products テーブルを作成しましょう。

$ rails db:migrate

db/schema.rb に 下記のように products テーブルが追加されていれば OK です。

db/schema.rb
create_table "products", force: :cascade do |t|
  t.string "name", null: false
  t.text "description", null: false
  t.integer "price", null: false
  t.integer "stock", default: 0, null: false
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
end

ついでに、バリデーションの設定も行いましょう。app/models/product.rb を開いて下記コードを記述してください。

app/models/product.rb
class Product < ApplicationRecord
+ with_options presence: true do
+   validates :name
+   validates :description
+   validates :price
+   validates :stock
+ end
end

上記の記述により、商品を登録・編集する際に、設定したカラムが空の場合にバリデーションではじくことができます。

コントローラを作成しよう

次に、商品を閲覧・作成・編集する機能を処理するコントローラを作成します。
下記コマンドを実行して、必要なコントローラを作成しましょう。

$ rails g controller admin/products index show new edit --no-helper

今回、

  1. config/routes.rb へのルート追加
  2. assets の作成
  3. テストの作成
  4. helper の作成

をスキップしています。4. だけは、config/application.rb で自動生成の対象から除外していないため、--no-helper というオプションをつけています。
また、admin ディレクトリ配下 に ProductsController を作成するために、admin/products としています。

続いて、ルーティングの設定を行います。
config/routes.rb を開いて下記コードを追加してください。

config/routes.rb
Rails.application.routes.draw do
  devise_for :admins, controllers: {
    sessions: 'admin/sessions'
  }
  devise_for :customers, controllers: {
    sessions: 'customer/sessions',
    registrations: 'customer/registrations'
  }
  root to: 'pages#home'
+ namespace :admin do
+   resources :products, only: %i[index show new create edit update]
+ end

  get '/up/', to: 'up#index', as: :up
  get '/up/databases', to: 'up#databases', as: :up_databases
end
namespace :admin do
end

で囲むことで、囲まれたパスに「admin/」が付与され、コントローラのファイル構成を反映する形になります。
念の為、生成されたパスを確認してみましょう。

$ rails routes | grep products
    admin_products GET    /admin/products(.:format)             admin/products#index
                   POST   /admin/products(.:format)             admin/products#create
 new_admin_product GET    /admin/products/new(.:format)         admin/products#new
edit_admin_product GET    /admin/products/:id/edit(.:format)    admin/products#edit
     admin_product GET    /admin/products/:id(.:format)         admin/products#show
                   PATCH  /admin/products/:id(.:format)         admin/products#update
                   PUT    /admin/products/:id(.:format)         admin/products#update

上記の結果をみると、例えば「/admin/products」に GET メソッドでアクセスしたとき、「admin/products#index」が呼び出されるといった具合に、見事に URL のパスと、ファイル構成が合致しています。

では次に、コントローラの各アクションの中身を記述していきます。
app/controllers/admin/products_controller.rb を開いて下記コードを記述してください。

app/controllers/admin/products_controller.rb
class Admin::ProductsController < ApplicationController
  before_action :authenticate_admin!
  before_action :set_product, only: %i[show edit update]

  def index
    @products = Product.all
  end

  def new
    @product = Product.new
  end

  def create
    @product = Product.new(product_params)
    if @product.save
      redirect_to admin_product_path(@product)
    else
      render :new
    end
  end

  def show; end

  def edit; end

  def update
    if @product.update(product_params)
      redirect_to admin_product_path(@product)
    else
      render :edit
    end
  end

  private

  def set_product
    @product = Product.find(params[:id])
  end

  def product_params
    params.require(:product).permit(:name, :description, :price, :stock)
  end
end

通常の CRUD 処理を実装していますが、ポイントが二点あります。

一点目は、before_action :authenticate_admin! の部分です。
商品を閲覧・作成・編集する機能はログイン済の管理者のみ権限を付与する形にしますので、authenticate_admin! を記述しました。authenticate_admin! の admin はモデル名になります。
二点目は、before_action :set_product, only: %i[show edit update] の部分です。
パラメータの id をもとに product を一件取得するという処理が、show・edit・update アクションで共通しているため、before_action でひとまとめにしました。こうすることで、重複する記述を一箇所にまとめることができます(DRY: Don’t Repeat Yourself)。

画像投稿機能を作成しよう

今回作成する EC サイトでは、商品を登録する際に画像も一緒にアップロードします。
そこで、Active Storage を用いた画像投稿機能を実装しましょう。

Active Storage とは、Rails 5.2 から追加されたファイルアップロード機能です。
単体のファイルのみでなく、複数枚のファイルをデータベースのデータと紐づけたり、画像の加工やダウンロードすることもできます。

今回、商品の画像を登録する際に、Active Storage を採用しました。
では最初に、Active Storage のインストールを行いましょう。
下記コマンドを実行してください。

$ rails active_storage:install

コマンド実行後、新たにマイグレーションファイルが生成されるはずですので、マイグレーションの実行も行いましょう。

$ rails db:migrate

マイグレーションの実行後、db/schema.rb に三つのテーブルが追加されていれば OK です。

db/schema.rb
create_table "active_storage_attachments", force: :cascade do |t|
  t.string "name", null: false
  t.string "record_type", null: false
  t.bigint "record_id", null: false
  t.bigint "blob_id", null: false
  t.datetime "created_at", null: false
  t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
  t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
end

create_table "active_storage_blobs", force: :cascade do |t|
  t.string "key", null: false
  t.string "filename", null: false
  t.string "content_type"
  t.text "metadata"
  t.string "service_name", null: false
  t.bigint "byte_size", null: false
  t.string "checksum"
  t.datetime "created_at", null: false
  t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
end

create_table "active_storage_variant_records", force: :cascade do |t|
  t.bigint "blob_id", null: false
  t.string "variation_digest", null: false
  t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end

作成されたテーブルの役割は下記の通りです。

テーブル名 役割
active_storage_attachments アップロードしたファイルデータと Active Record を紐付ける(中間テーブル)
active_storage_blobs アップロードしたファイルを保存する(BLOB型)
active_storage_variant_records アップロードしたファイルのデータの variant に関する情報を保存する

※ Blob 型はバイナリデータを扱うデータ型で格納できるデータのサイズを指定しません。画像ファイルなどを格納する場合などにも使われます。

※ variant とは、Rails において Blob 型のデータに対して使えるメソッドの一つです。画像に対してvariant メソッドを使うと、画像のサイズ変更や回転などの処理を加えることができます。

Active Storage を利用するためのテーブルが作成できたところで、画像データと products テーブルを紐づけるコードを追加しましょう。app/models/product.rb を開いて、下記コードを追加してください。

app/models/product.rb
class Product < ApplicationRecord
  with_options presence: true do
    validates :name
    validates :description
    validates :price
    validates :stock
  end
+ has_one_attached :image
end

シンボルで指定するカラム名は任意ですが、わかりやすい名前にした方が better です。今回は画像を扱うため、image としました。
これで、あたかも products テーブルに image カラムが存在するかのように実装することができます。
ということは、関連付けた Active Storage のデータをパラメータとして受け取れるよう、ストロングパラメータに対象のカラム名を追加する必要がありますね。app/controllers/admin/products_controller.rb を開いて、下記コードを追加しましょう。

app/controllers/admin/products_controller.rb
class Admin::ProductsController < ApplicationController
  before_action :authenticate_admin!
  before_action :set_product, only: %i[show edit update]

  def index
    @products = Product.all
  end

  def new
    @product = Product.new
  end

  def create
    @product = Product.new(product_params)
    if @product.save
      redirect_to admin_product_path(@product)
    else
      render :new
    end
  end

  def show; end

  def edit; end

  def update
    if @product.update(product_params)
      redirect_to admin_product_path(@product)
    else
      render :edit
    end
  end

  private

  def set_product
    @product = Product.find(params[:id])
  end

  def product_params
- params.require(:product).permit(:name, :description, :price, :stock)
+ params.require(:product).permit(:name, :description, :price, :stock, :image)
  end
end

また、バリデーションに image カラムを追加しておきましょう。

app/models/product.rb
class Product < ApplicationRecord
  with_options presence: true do
    validates :name
    validates :description
    validates :price
    validates :stock
+   validates :image
  end
  has_one_attached :image
end

これで、画像を投稿する機能が完成しました。
あとは、ビューに画像をアップロードするフィールド・画像を表示するタグを用意するだけです。

ビューを作成しよう

それでは、最後に Tailwind CSS を使ってビューを作成していきましょう。
まずは、商品の新規登録画面です。app/views/admin/products/new.html.erb を開いてください。

app/views/admin/products/new.html.erb
<div class="mb-6 text-center">
  <span class="text-3xl font-bold">
    New Product
  </span>
</div>

<div class='flex flex-wrap justify-center'>
  <%= form_with model: @product, url: admin_products_path, local: true, class: "xl:w-8/12 md:w-10/12", data: { turbo: false }  do |f| %>
    <%= render "admin/shared/error_messages", resource: @product %>
    <div class="mb-6">
      <%= f.label :name, class: "mb-2 block text-sm text-gray-600" %>
      <%= f.text_field :name, class: "w-full rounded-md border border-gray-300 py-2 px-3 placeholder:text-gray-300 focus:border-indigo-300 focus:outline-none focus:ring focus:ring-indigo-100" %>
    </div>
    <div class="mb-6">
      <%= f.label :description, class: "mb-2 block text-sm text-gray-600" %>
      <%= f.text_area :description, rows: "5", class: "w-full rounded-md border border-gray-300 py-2 px-3 placeholder:text-gray-300 focus:border-indigo-300 focus:outline-none focus:ring focus:ring-indigo-100" %>
    </div>
    <div class="mb-6">
      <%= f.label :price, class: "mb-2 block text-sm text-gray-600" %>
      <%= f.number_field :price, class: "w-full rounded-md border border-gray-300 py-2 px-3 placeholder:text-gray-300 focus:border-indigo-300 focus:outline-none focus:ring focus:ring-indigo-100" %>
    </div>
    <div class="mb-6">
      <%= f.label :stock, class: "mb-2 block text-sm text-gray-600" %>
      <%= f.number_field :stock, class: "w-full rounded-md border border-gray-300 py-2 px-3 placeholder:text-gray-300 focus:border-indigo-300 focus:outline-none focus:ring focus:ring-indigo-100" %>
    </div>
    <div class="mb-6">
      <%= f.label :image, class: "mb-2 block text-sm text-gray-600" %>
      <%= f.file_field :image, class: "block w-full px-3 py-1.5 text-base font-normal text-gray-700 bg-white bg-clip-padding border border-solid border-gray-300 rounded transition ease-in-out m-0 focus:text-gray-700 focus:bg-white focus:border-blue-600 focus:outline-none" %>
    </div>
    <%= f.submit class: "inline-flex w-full items-center justify-center rounded-md bg-indigo-500 p-3 text-white duration-100 ease-in-out hover:bg-indigo-600 focus:outline-none cursor-pointer" %>
  <% end %>
</div>

また、バリデーションのエラーメッセージは、devise でビューを作成した際に生成された admin/shared/_error_messages を使うため、デザインを修正しましょう。

app/views/admin/shared/_error_messages.html.erb
<% if resource.errors.any? %>
  <div class="bg-orange-100 border-l-4 border-orange-500 text-orange-700 p-4 mb-5" role="alert">
    <p class="font-bold">
      <%= I18n.t("errors.messages.not_saved",
                  count: resource.errors.count,
                  resource: resource.class.model_name.human.downcase)
      %>
    </p>
    <ul>
      <% resource.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
    </ul>
  </div>
<% end %>

ビューの記述が完了しましたら、http://localhost:8000/admin/products/new にアクセスして商品を登録してみましょう。このとき、管理者でログインしていることを確認してください。もしも、管理者でログインしていない場合、ログイン画面にリダイレクトされるはずです。

下記のように全てのフォームを入力します。

「Create Product」ボタンを押下して、商品の詳細画面にリダイレクトされれば OK です。
また、ログから

  • INSERT INTO "products"
  • INSERT INTO "active_storage_blobs"
  • INSERT INTO "active_storage_attachments"

という SQL 文が発行されていることを確認してください。発行されていれば、テーブルにデータが挿入されたということです。

それでは、次に商品の詳細画面を作成しましょう。app/views/admin/products/show.html.erb を開いて、下記コードを記述してください。

app/views/admin/products/show.html.erb
<div class="relative mx-auto max-w-screen-xl p-6">
  <div class="grid grid-cols-1 items-start gap-9 md:grid-cols-2">
    <div>
      <%= image_tag @product.image, class: "aspect-square w-full rounded-xl object-cover" %>
    </div>

    <div class="sticky top-0">
      <div class="flex flex-col justify-between">
        <div class="flex justify-between mb-6">
          <div class="max-w-[35ch]">
            <h1 class="text-2xl font-bold">
              <%= @product.name %>
            </h1>
          </div>
          <p class="text-2xl font-bold"><%= number_to_currency(@product.price, unit: "¥", strip_insignificant_zeros: true) %></p>
        </div>

        <div class="mb-8">
          <p>
            <%= @product.description %>
          </p>
        </div>

        <div class="flex">
          <div>
            <% if @product.stock > 0 %>
              <span class="bg-blue-100 text-blue-800 text-sm font-semibold p-2.5 rounded">In stock (<%= @product.stock %>)</span>
            <% else %>
              <span class="bg-red-100 text-red-800 text-sm font-semibold p-2.5 rounded">Out of stock</span>
            <% end %>
          </div>
          <div class="ml-4">
          <%= link_to edit_admin_product_path(@product), class: "text-center cursor-pointer focus:outline-none text-white bg-green-700 hover:bg-green-800 focus:ring-4 focus:ring-green-300 font-medium rounded-lg text-sm px-5 py-2.5 mb-2" do %>
            Edit Product
          <% end %>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

http://localhost:8000/admin/products/1 にアクセスして下記のような画面が表示されていれば OK です。id は作成した product の id を指定してください。

先ほど、作成した商品の情報が表示されていれば OK です。

続いて、商品の編集画面です。app/views/admin/products/edit.html.erb を開いて下記コードを記述しましょう。

app/views/admin/products/edit.html.erb
<div class="mb-6 text-center">
  <span class="text-3xl font-bold">
    Edit Product
  </span>
</div>

<div class='flex flex-wrap justify-center'>
  <%= form_with model: @product, url: admin_product_path(@product), local: true, class: "xl:w-8/12 md:w-10/12", data: { turbo: false }  do |f| %>
    <%= render "admin/shared/error_messages", resource: @product %>
    <div class="mb-6">
      <%= f.label :name, class: "mb-2 block text-sm text-gray-600" %>
      <%= f.text_field :name, class: "w-full rounded-md border border-gray-300 py-2 px-3 placeholder:text-gray-300 focus:border-indigo-300 focus:outline-none focus:ring focus:ring-indigo-100" %>
    </div>
    <div class="mb-6">
      <%= f.label :description, class: "mb-2 block text-sm text-gray-600" %>
      <%= f.text_area :description, rows: "5", class: "w-full rounded-md border border-gray-300 py-2 px-3 placeholder:text-gray-300 focus:border-indigo-300 focus:outline-none focus:ring focus:ring-indigo-100" %>
    </div>
    <div class="mb-6">
      <%= f.label :price, class: "mb-2 block text-sm text-gray-600" %>
      <%= f.number_field :price, class: "w-full rounded-md border border-gray-300 py-2 px-3 placeholder:text-gray-300 focus:border-indigo-300 focus:outline-none focus:ring focus:ring-indigo-100" %>
    </div>
    <div class="mb-6">
      <%= f.label :stock, class: "mb-2 block text-sm text-gray-600" %>
      <%= f.number_field :stock, class: "w-full rounded-md border border-gray-300 py-2 px-3 placeholder:text-gray-300 focus:border-indigo-300 focus:outline-none focus:ring focus:ring-indigo-100" %>
    </div>
    <div class="mb-6">
      <%= f.label :image, class: "mb-2 block text-sm text-gray-600" %>
      <%= f.file_field :image, class: "block w-full px-3 py-1.5 text-base font-normal text-gray-700 bg-white bg-clip-padding border border-solid border-gray-300 rounded transition ease-in-out m-0 focus:text-gray-700 focus:bg-white focus:border-blue-600 focus:outline-none" %>
    </div>
    <%= f.submit class: "inline-flex w-full items-center justify-center rounded-md bg-indigo-500 p-3 text-white duration-100 ease-in-out hover:bg-indigo-600 focus:outline-none" %>
  <% end %>
</div>

記述が完了しましたら、http://localhost:8000/admin/products/1/edit にアクセスしてください。下記のような画面が表示されていれば OK です。id は作成した product の id を指定してください。

試しに、stock を編集してみましょう。
編集した値が、リダイレクト先の詳細画面に反映されていれば、編集成功です。

また、ログに UPDATE "products" という SQL が発行されていることを確認してください。発行されていればレコードの更新が成功したということです。

それでは最後に、商品一覧画面を作成しましょう。app/views/admin/products/index.html.erb を開いて下記コードを記述してください。

app/views/admin/products/index.html.erb
<div class="mb-8 text-center">
  <span class="text-3xl font-bold">
    Products
  </span>
</div>

<div class="flex flex-wrap">
  <div class="flex-none w-full max-w-full px-6">
    <%= link_to new_admin_product_path, class: "mb-4 inline-flex items-center justify-center rounded-md bg-indigo-500 p-3 text-white duration-100 ease-in-out hover:bg-indigo-600 focus:outline-none" do %>
      Add New Product
    <% end %>
    <div class="relative flex flex-col min-w-0 mb-6 break-words bg-white border-0 border-transparent border-solid shadow-soft-xl rounded-2xl bg-clip-border">
      <div class="flex-auto px-0 pt-0 pb-2">
        <div class="p-0 overflow-x-auto">
          <table class="items-center w-full mb-0 align-top border-gray-200 text-slate-500">
            <thead class="align-bottom">
              <tr>
                <th class="px-6 py-3 font-bold text-left uppercase align-middle bg-transparent border-b border-gray-200 shadow-none text-xxs border-b-solid tracking-none whitespace-nowrap text-slate-400 opacity-70">Image & Name</th>
                <th class="px-6 py-3 pl-2 font-bold text-left uppercase align-middle bg-transparent border-b border-gray-200 shadow-none text-xxs border-b-solid tracking-none whitespace-nowrap text-slate-400 opacity-70">Price</th>
                <th class="px-6 py-3 font-bold text-center uppercase align-middle bg-transparent border-b border-gray-200 shadow-none text-xxs border-b-solid tracking-none whitespace-nowrap text-slate-400 opacity-70">Stock</th>
                <th class="px-6 py-3 font-bold text-center uppercase align-middle bg-transparent border-b border-gray-200 shadow-none text-xxs border-b-solid tracking-none whitespace-nowrap text-slate-400 opacity-70">CreatedAt</th>
                <th class="px-6 py-3 font-bold text-center uppercase align-middle bg-transparent border-b border-gray-200 shadow-none text-xxs border-b-solid tracking-none whitespace-nowrap text-slate-400 opacity-70">UpdatedAt</th>
                <th class="px-6 py-3 font-semibold capitalize align-middle bg-transparent border-b border-gray-200 border-solid shadow-none tracking-none whitespace-nowrap text-slate-400 opacity-70"></th>
              </tr>
            </thead>
            <tbody>
              <% @products.each do |product| %>
                <tr>
                  <td class="p-2 align-middle bg-transparent border-b whitespace-nowrap shadow-transparent">
                    <%= link_to admin_product_path(product) do %>
                      <div class="flex px-2 py-1">
                        <div class="w-16 mr-5">
                          <%= image_tag product.image, class: "inline-flex items-center justify-center mr-4 text-white transition-all duration-200 ease-soft-in-out text-sm rounded-xl object-fill" %>
                        </div>
                        <div class="flex flex-col justify-center">
                          <h6 class="text-lg font-bold"><%= product.name %></h6>
                        </div>
                      </div>
                    <% end %>
                  </td>
                  <td class="p-2 align-middle bg-transparent border-b whitespace-nowrap shadow-transparent">
                    <p class="font-semibold leading-tight text-sm"><%= number_to_currency(product.price, unit: "¥", strip_insignificant_zeros: true) %></p>
                  </td>
                  <td class="p-2 text-center align-middle bg-transparent border-b whitespace-nowrap shadow-transparent">
                    <span class="font-semibold leading-tight text-sm text-slate-400"><%= product.stock %></span>
                  </td>
                  <td class="p-2 text-center align-middle bg-transparent border-b whitespace-nowrap shadow-transparent">
                    <span class="font-semibold leading-tight text-sm text-slate-400"><%= l product.created_at, format: :short %></span>
                  </td>
                  <td class="p-2 text-center align-middle bg-transparent border-b whitespace-nowrap shadow-transparent">
                    <span class="font-semibold leading-tight text-sm text-slate-400"><%= l product.updated_at, format: :short %></span>
                  </td>
                  <td class="p-2 align-middle bg-transparent border-b whitespace-nowrap shadow-transparent">
                    <%= link_to edit_admin_product_path(product), class: "font-semibold leading-tight text-xs text-slate-400" do %>
                      Edit
                    <% end %>
                  </td>
                </tr>
              <% end %>
            </tbody>
          </table>
        </div>
      </div>
    </div>
  </div>
</div>

http://localhost:8000/admin/products にアクセスして、下記のような画面が表示されれば OK です。

商品を追加すると、一覧画面の商品の個数が増えますね。

これで、商品の作成・閲覧・編集をする画面全て完成しました。

ついでに、ログイン済のナビゲーションバーに、商品一覧へのリンクを追加してあげましょう。
app/views/layouts/application.html.erb を開いて下記コードを追加してください。
<% if admin_signed_in? %> の中身です。

app/views/layouts/application.html.erb
<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Ecommerce</title>

    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="A production ready example Rails app that's using Docker and Docker Compose.">

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>

    <%# Generated with: https://realfavicongenerator.net/ %>
    <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
    <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
    <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
    <link rel="manifest" href="/site.webmanifest">
    <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#61b9d2">
    <meta name="msapplication-TileColor" content="#da532c">
    <meta name="theme-color" content="#ffffff">
  </head>

  <body class="flex flex-col h-screen justify-between">
    <div class='bg-gray-900 mb-8 z-10'>
      <div class='container mx-auto flex max-w-4xl items-center px-2 py-5'>
        <div class='mx-auto flex w-full flex-wrap items-center'>
          <div class='flex w-full justify-center font-extrabold text-white lg:w-1/2 lg:justify-start'>
            <%= link_to root_path, class: "text-2xl text-gray-900 no-underline hover:text-gray-900 hover:no-underline" do %>
              🛍️ &nbsp; <span class=' text-gray-200'>ECommerce</span>
            <% end %>
          </div>
          <div class='flex w-full content-center justify-between pt-2 lg:w-1/2 lg:justify-end lg:pt-0'>
            <ul class='list-reset flex flex-1 items-center justify-center lg:flex-none h-12'>
              <% if admin_signed_in? %>
                <li class='px-4 text-white no-underline'>
                  <%= link_to admin_products_path do %>
                    <svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd">
                      <path fill="white" d="M11.5 23l-8.5-4.535v-3.953l5.4 3.122 3.1-3.406v8.772zm1-.001v-8.806l3.162 3.343 5.338-2.958v3.887l-8.5 4.534zm-10.339-10.125l-2.161-1.244 3-3.302-3-2.823 8.718-4.505 3.215 2.385 3.325-2.385 8.742 4.561-2.995 2.771 2.995 3.443-2.242 1.241v-.001l-5.903 3.27-3.348-3.541 7.416-3.962-7.922-4.372-7.923 4.372 7.422 3.937v.024l-3.297 3.622-5.203-3.008-.16-.092-.679-.393v.002z"/>
                    </svg>
                  <% end %>
                </li>
                <li class='no-underline'>
                  <div class="flex items-center justify-center">
                    <div class="relative inline-block text-left dropdown">
                      <button class="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out  rounded-md" type="button" aria-haspopup="true" aria-expanded="true" aria-controls="headlessui-menu-items-117">
                        <svg class="h-6 w-6" id="dropdownDividerButton" data-dropdown-toggle="dropdownDivider" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
                          <path fill="white" d="M288 320a224 224 0 1 0 448 0 224 224 0 1 0-448 0zm544 608H160a32 32 0 0 1-32-32v-96a160 160 0 0 1 160-160h448a160 160 0 0 1 160 160v96a32 32 0 0 1-32 32z">
                          </path>
                        </svg>
                      </button>
                      <div class="opacity-0 invisible dropdown-menu transition-all duration-300 transform origin-top-right -translate-y-2 scale-95">
                        <div class="absolute right-0 w-56 mt-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none" aria-labelledby="headlessui-menu-button-1" id="headlessui-menu-items-117" role="menu">
                          <div class="px-4 py-3">         
                            <p class="text-sm leading-5">Signed in as</p>
                            <p class="text-sm font-medium leading-5 text-gray-900 truncate"><%= current_admin.email %></p>
                          </div>
                          <div class="py-1">
                            <%= link_to destroy_admin_session_path, data: { turbo_method: :delete }, class: "text-gray-700 flex justify-between w-full px-4 py-2 text-sm leading-5 text-left" do %>
                              Sign out
                            <% end %>
                          </div>
                        </div>
                      </div>
                    </div>
                  </div>
                </li>
              <% elsif customer_signed_in? %>
                <li class='no-underline'>
                  <div class="flex items-center justify-center">
                    <div class="relative inline-block text-left dropdown">
                      <button class="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out  rounded-md" type="button" aria-haspopup="true" aria-expanded="true" aria-controls="headlessui-menu-items-117">
                        <svg class="h-6 w-6" id="dropdownDividerButton" data-dropdown-toggle="dropdownDivider" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
                          <path fill="white" d="M288 320a224 224 0 1 0 448 0 224 224 0 1 0-448 0zm544 608H160a32 32 0 0 1-32-32v-96a160 160 0 0 1 160-160h448a160 160 0 0 1 160 160v96a32 32 0 0 1-32 32z">
                          </path>
                        </svg>
                      </button>
                      <div class="opacity-0 invisible dropdown-menu transition-all duration-300 transform origin-top-right -translate-y-2 scale-95">
                        <div class="absolute right-0 w-56 mt-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none" aria-labelledby="headlessui-menu-button-1" id="headlessui-menu-items-117" role="menu">
                          <div class="px-4 py-3">         
                            <p class="text-sm leading-5">Signed in as</p>
                            <p class="text-sm font-medium leading-5 text-gray-900 truncate"><%= current_customer.email %></p>
                          </div>
                          <div class="py-1">
                            <%= link_to edit_customer_registration_path, class: "text-gray-700 flex justify-between w-full px-4 py-2 text-sm leading-5 text-left" do %>
                              Account settings
                            <% end %>
                          </div>
                          <div class="py-1">
                            <%= link_to destroy_customer_session_path, data: { turbo_method: :delete }, class: "text-gray-700 flex justify-between w-full px-4 py-2 text-sm leading-5 text-left" do %>
                              Sign out
                            <% end %>
                          </div>
                        </div>
                      </div>
                    </div>
                  </div>
                </li>
              <% else %>
                <li class='px-4 text-white no-underline'>
                  <%= link_to "Sign Up", new_customer_registration_path %>
                </li>
                <li class='px-4 text-white no-underline'>
                  <%= link_to "Sign In", new_customer_session_path %>
                </li>
              <% end %>
            </ul>
          </div>
        </div>
      </div>
    </div>

    <% if flash[:notice] %>
      <div class="p-4 mb-4 text-md text-blue-700 text-center font-bold">
        <%= notice %>
      </div>
    <% end %>
    <% if flash[:alert] %>
      <div class="p-4 mb-4 text-md text-red-700 text-center font-bold">
        <%= alert %>
      </div>
    <% end %>

    <main class="container mx-auto sm:w-10/12 lg:w-9/12 mb-8 z-0">
      <%= yield %>
    </main>

    <style>
    .dropdown:focus-within .dropdown-menu {
      opacity:1;
      transform: translate(0) scale(1);
      visibility: visible;
    }
    </style>

    <footer class="text-center mt-6 pb-6 h-10">
      <p class="text-gray-500">
        Copyright © FarStep All Rights Reserved.
      </p>
    </footer>
  </body>
</html>

下記のように新たにアイコンが追加されれば OK です。
アイコンをクリックした際に、商品一覧ページに遷移することを確認してください。

最後に、RuboCop を実行し、必要であればコードを修正しましょう。

$ rubocop

今回は、RuboCop の静的解析を全て PASS したようです。

Inspecting 47 files
...............................................

47 files inspected, no offenses detected

もしも指摘事項があれば修正してください。
コミットしておきましょう。

$ git add . && git commit -m "Implementation on the admin's side of the product"

おわりに

お疲れ様でした。
本 Chapter では、管理者側の商品に関する機能を実装しました。
基本的な CRUD 処理の復習になれば幸いです。
次の Chapter では顧客側の商品に関する機能を実装します。