更多内容请访问 rubyonrails.org:

1. 简介

电子商务商店通常有用于分享产品的心愿单。客户可以使用心愿单来跟踪他们想购买的产品,或者与朋友和家人分享以获取礼物创意。

让我们开始吧!

2. 心愿单模型

我们的电子商务商店有产品和用户,这些我们已经在之前的教程中构建好了。这些是我们构建心愿单所需的基础。每个心愿单都属于一个用户,并包含一个产品列表。

让我们从创建 Wishlist 模型开始。

$ bin/rails generate model Wishlist user:belongs_to name products_count:integer

这个模型有 3 个属性

  • user:belongs_toWishlist 与拥有它的 User 关联起来
  • name 我们也将用于友好的 URL
  • products_count 用于计数缓存,以统计心愿单上有多少产品

为了将 Wishlist 与多个 Products 关联起来,我们需要添加一个连接表。

$ bin/rails generate model WishlistProduct product:belongs_to wishlist:belongs_to

我们不希望同一个 Product 多次出现在 Wishlist 上,所以让我们在刚刚创建的迁移中添加一个索引

class CreateWishlistProducts < ActiveRecord::Migration[8.0]
  def change
    create_table :wishlist_products do |t|
      t.belongs_to :product, null: false, foreign_key: true
      t.belongs_to :wishlist, null: false, foreign_key: true

      t.timestamps
    end

    add_index :wishlist_products, [:product_id, :wishlist_id], unique: true
  end
end

最后,让我们在 Product 模型中添加一个计数器,以跟踪该产品在多少个 Wishlist 上。

$ bin/rails generate migration AddWishlistsCountToProducts wishlists_count:integer

2.1. 默认计数缓存值

在运行这些新的迁移之前,让我们为计数缓存列设置一个默认值,这样所有现有记录都从零计数开始,而不是 NULL。

打开 db/migrate/<timestamp>_create_wishlists.rb 迁移并添加默认选项

class CreateWishlists < ActiveRecord::Migration[8.0]
  def change
    create_table :wishlists do |t|
      t.belongs_to :user, null: false, foreign_key: true
      t.string :name
      t.integer :products_count, default: 0

      t.timestamps
    end
  end
end

然后打开 db/migrate/<timestamp>_add_wishlists_count_to_products.rb 并在此处也添加一个默认值

class AddWishlistsCountToProducts < ActiveRecord::Migration[8.0]
  def change
    add_column :products, :wishlists_count, :integer, default: 0
  end
end

现在让我们运行迁移

$ bin/rails db:migrate

2.2. 关联和计数缓存

现在数据库表已经创建,让我们更新 Rails 中的模型以包含这些新的关联。

app/models/user.rb 中,添加以下内容

class User < ApplicationRecord
  has_secure_password
  has_many :sessions, dependent: :destroy
  has_many :wishlists, dependent: :destroy

  # ...

我们在 wishlists 关联上设置了 dependent: :destroy,这样当用户被删除时,他们的心愿单也会被删除。

然后在 app/models/product.rb 中,添加

class Product < ApplicationRecord
  include Notifications

  has_many :subscribers, dependent: :destroy
  has_many :wishlist_products, dependent: :destroy
  has_many :wishlists, through: :wishlist_products
  has_one_attached :featured_image
  has_rich_text :description

我们为 Product 添加了两个关联。首先,我们将 Product 模型与 WishlistProduct 连接表关联起来。通过这个连接表,我们的第二个关联告诉 Rails,一个 Product 通过同一个 WishlistProduct 连接表是许多 Wishlist 的一部分。从 Product 记录中,我们可以直接访问 Wishlists,Rails 会知道在 SQL 查询中自动 JOIN 这些表。

我们还将 wishlist_products 设置为 dependent: :destroy。当 Product 被销毁时,它将自动从任何心愿单中移除。

计数缓存存储关联记录的数量,以避免每次需要计数时都运行单独的查询。因此,在 app/models/wishlist.rb 中,让我们更新两个关联以启用计数缓存

class WishlistProduct < ApplicationRecord
  belongs_to :product, counter_cache: :wishlists_count
  belongs_to :wishlist, counter_cache: :products_count

  validates :product_id, uniqueness: { scope: :wishlist_id }
end

我们指定了要更新关联模型上的列名。对于 Product 模型,我们要使用 wishlists_count 列;对于 Wishlist,我们要使用 products_count。这些计数缓存会在 WishlistProduct 被创建或销毁时更新。

uniqueness 验证还会告诉 Rails 检查产品是否已在心愿单上。这与 wishlist_product 表上的唯一索引配合使用,以便在数据库级别也进行验证。

最后,让我们用它的关联更新 app/models/wishlist.rb

class Wishlist < ApplicationRecord
  belongs_to :user
  has_many :wishlist_products, dependent: :destroy
  has_many :products, through: :wishlist_products
end

就像 Product 一样,wishlist_products 使用 dependent: :destroy 选项在删除心愿单时自动移除连接表记录。

2.3. 友好的 URL

心愿单通常与朋友和家人共享。默认情况下,Wishlist 的 URL 中的 ID 是一个简单的整数。这意味着我们无法轻松地通过 URL 来确定它是哪个 Wishlist

Active Record 有一个 to_param 类方法,可用于生成更具描述性的 URL。让我们在模型中尝试一下

class Wishlist < ApplicationRecord
  belongs_to :user
  has_many :wishlist_products, dependent: :destroy
  has_many :products, through: :wishlist_products

  def to_param
    "#{id}-#{name.squish.parameterize}"
  end
end

这将创建一个 to_param 实例方法,该方法返回一个由 idname 通过连字符连接组成的 URL 参数字符串。name 通过使用 squish 清理空白和 parameterize 替换特殊字符来确保 URL 安全。

让我们在 Rails 控制台中测试一下

$ bin/rails console

然后为你的 User 在数据库中创建一个 Wishlist

store(dev)> user = User.first
store(dev)> wishlist = user.wishlists.create!(name: "Example Wishlist")
store(dev)> wishlist.to_param
=> "1-example-wishlist"

完美!

现在让我们尝试使用这个参数查找这条记录

store(dev)> wishlist = Wishlist.find("1-example-wishlist")
=> #<Wishlist:0x000000012bb71d68
 id: 1,
 user_id: 1,
 name: "Example Wishlist",
 products_count: nil,
 created_at: "2025-07-22 15:21:29.036470000 +0000",
 updated_at: "2025-07-22 15:21:29.036470000 +0000">

成功了!但是怎么回事?我们不是必须使用整数来查找记录吗?

我们使用 to_param 的方式利用了 Ruby 如何将字符串转换为整数。让我们在控制台中将该参数转换为整数,使用 to_i

store(dev)> "1-example-wishlist".to_i
=> 1

Ruby 解析字符串,直到找到一个不是有效数字的字符。在这种情况下,它会在第一个连字符处停止。然后 Ruby 将字符串 "1" 转换为整数并返回 1。这使得 to_param 在 ID 前缀时无缝工作。

现在我们了解了它是如何工作的,让我们将 to_param 方法替换为对类方法快捷方式的调用。

class Wishlist < ApplicationRecord
  belongs_to :user
  has_many :wishlist_products, dependent: :destroy
  has_many :products, through: :wishlist_products

  to_param :name
end

to_param 类方法定义了一个同名的实例方法。参数是用于生成参数的方法名。我们告诉它使用 name 属性来生成参数。

to_param 还有一项功能是逐字截断超过 20 个字符的值。

让我们在 Rails 控制台中重新加载代码并测试一个很长的 Wishlist 名称。

store(dev)> reload!
store(dev)> Wishlist.last.update(name: "A really, really long wishlist name!")
store(dev)> Wishlist.last.to_param
=> "1-a-really-really-long"

您可以看到名称被截断为最接近 20 个字符的单词。

好的,关闭 Rails 控制台,让我们开始在 UI 中实现心愿单。

3. 将产品添加到心愿单

用户可能第一次使用心愿单的地方是 Product 详情页。他们可能会浏览产品并想保存一个以备后用。让我们先从构建这个功能开始。

3.1. 添加到心愿单表单

首先在 config/routes.rb 中添加此表单提交的路由

  resources :products do
    resource :wishlist, only: [ :create ], module: :products
    resources :subscribers, only: [ :create ]
  end

我们为这条路由使用单数资源,因为我们不一定预先知道心愿单 ID。我们还使用 module: :products 将此控制器限定在 Products 命名空间内。

app/views/products/show.html.erb 中,添加以下内容以渲染一个新的心愿单局部视图

<p><%= link_to "Back", products_path %></p>

<section class="product">
  <%= image_tag @product.featured_image if @product.featured_image.attached? %>

  <section class="product-info">
    <% cache @product do %>
      <h1><%= @product.name %></h1>
      <%= @product.description %>
    <% end %>

    <%= render "inventory", product: @product %>
    <%= render "wishlist", product: @product %>
  </section>
</section>

然后创建 app/views/products/_wishlist.html.erb,内容如下

<% if authenticated? %>
  <%= form_with url: product_wishlist_path(product) do |form| %>
    <div>
      <%= form.collection_select :wishlist_id, Current.user.wishlists, :id, :name %>
    </div>

    <div>
      <%= form.submit "Add to wishlist" %>
    </div>
  <% end %>
<% else %>
  <%= link_to "Add to wishlist", sign_up_path %>
<% end %>

如果用户未登录,他们会看到注册链接。已登录用户会看到一个表单,可以选择心愿单并将产品添加到其中。

接下来,在 app/controllers/products/wishlists_controller.rb 中创建控制器来处理此表单,内容如下

class Products::WishlistsController < ApplicationController
  before_action :set_product
  before_action :set_wishlist

  def create
    @wishlist.wishlist_products.create(product: @product)
    redirect_to @wishlist, notice: "#{@product.name} added to wishlist."
  end

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

    def set_wishlist
      @wishlist = Current.user.wishlists.find(params[:wishlist_id])
    end
end

由于我们在嵌套资源路由中,我们使用 :product_id 参数查找 Product

create 操作也比平时简单。如果产品已经在心愿单上,wishlist_product 记录将无法创建,但我们不需要通知用户此错误,因此无论哪种情况我们都可以重定向到心愿单。

现在,以我们之前为其创建心愿单的用户身份登录,并尝试将产品添加到心愿单。

3.2. 默认心愿单

这很好用,因为我们在 Rails 控制台中创建了一个心愿单,但是当用户没有任何心愿单时会发生什么呢?

运行以下命令删除数据库中的所有心愿单

$ bin/rails runner "Wishlist.destroy_all"

现在尝试访问一个产品并将其添加到心愿单中。

第一个问题是选择框将为空。表单不会向服务器提交 wishlist_id 参数,这将导致 Active Record 抛出错误。

ActiveRecord::RecordNotFound (Couldn't find Wishlist without an ID):

app/controllers/products/wishlists_controller.rb:16:in 'Products::WishlistsController#set_wishlist'

在这种情况下,如果用户没有任何心愿单,我们应该自动创建一个心愿单。这还有一个额外的好处,即逐步引导用户使用心愿单。

更新控制器中的 set_wishlist 以查找或创建心愿单

class Products::WishlistsController < ApplicationController
  before_action :set_product
  before_action :set_wishlist

  def create
    @wishlist.wishlist_products.create(product: @product)
    redirect_to @wishlist, notice: "#{@product.name} added to wishlist."
  end

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

    def set_wishlist
      if (id = params[:wishlist_id])
        @wishlist = Current.user.wishlists.find(id)
      else
        @wishlist = Current.user.wishlists.create(name: "My Wishlist")
      end
    end
end

为了改进我们的表单,如果用户没有任何心愿单,让我们隐藏选择框。更新 app/views/products/_wishlist.html.erb,内容如下

<% if authenticated? %>
  <%= form_with url: product_wishlist_path(product) do |form| %>
    <% if Current.user.wishlists.any? %>
      <div>
        <%= form.collection_select :wishlist_id, Current.user.wishlists, :id, :name %>
      </div>
    <% end %>

    <div>
      <%= form.submit "Add to wishlist" %>
    </div>
  <% end %>
<% else %>
  <%= link_to "Add to wishlist", sign_up_path %>
<% end %>

4. 管理心愿单

接下来,我们需要能够查看和管理我们的心愿单。

4.1. 心愿单控制器

首先在顶层添加心愿单的路由

Rails.application.routes.draw do
  # ...
  resources :products do
    resource :wishlist, only: [ :create ], module: :products
    resources :subscribers, only: [ :create ]
  end
  resource :unsubscribe, only: [ :show ]

  resources :wishlists

然后我们可以在 app/controllers/wishlists_controller.rb 中添加控制器,内容如下

class WishlistsController < ApplicationController
  allow_unauthenticated_access only: %i[ show ]
  before_action :set_wishlist, only: %i[ edit update destroy ]

  def index
    @wishlists = Current.user.wishlists
  end

  def show
    @wishlist = Wishlist.find(params[:id])
  end

  def new
    @wishlist = Wishlist.new
  end

  def create
    @wishlist = Current.user.wishlists.new(wishlist_params)
    if @wishlist.save
      redirect_to @wishlist, notice: "Your wishlist was created successfully."
    else
      render :new, status: :unprocessable_entity
    end
  end

  def edit
  end

  def update
    if @wishlist.update(wishlist_params)
      redirect_to @wishlist, status: :see_other, notice: "Your wishlist has been updated successfully."
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
    @wishlist.destroy
    redirect_to wishlists_path, status: :see_other
  end

  private

  def set_wishlist
    @wishlist = Current.user.wishlists.find(params[:id])
  end

  def wishlist_params
    params.expect(wishlist: [ :name ])
  end
end

这是一个非常标准的控制器,有几个重要的改变

  • 操作的范围限定为 Current.user.wishlists,因此只有所有者才能创建、更新和删除自己的心愿单
  • show 是公开可访问的,因此心愿单可以由任何人共享和查看

4.2. 心愿单视图

app/views/wishlists/index.html.erb 创建索引视图

<h1>Your Wishlists</h1>
<%= link_to "Create a wishlist", new_wishlist_path %>
<%= render @wishlists %>

这会渲染 _wishlist 局部视图,所以让我们在 app/views/wishlists/_wishlist.html.erb 创建它

<div>
  <%= link_to wishlist.name, wishlist %>
</div>

接下来,让我们在 app/views/wishlists/new.html.erb 中创建 new 视图

<h1>New Wishlist</h1>
<%= render "form", locals: { wishlist: @wishlist } %>

并在 app/views/wishlists/edit.html.erb 中创建 edit 视图

<h1>Edit Wishlist</h1>
<%= render "form", locals: { wishlist: @wishlist } %>

以及在 app/views/wishlists/_form.html.erb 中创建 _form 局部视图

<%= form_with model: @wishlist do |form| %>
  <% if form.object.errors.any? %>
    <div><%= form.object.errors.full_messages.to_sentence %></div>
  <% end %>

  <div>
    <%= form.label :name %>
    <%= form.text_field :name %>
  </div>

  <div>
    <%= form.submit %>
    <%= link_to "Cancel", form.object.persisted? ? form.object : wishlists_path %>
  </div>
<% end %>

接下来在 app/views/wishlists/show.html.erb 创建 show 视图

<h1><%= @wishlist.name %></h1>
<% if authenticated? && @wishlist.user == Current.user %>
  <%= link_to "Edit", edit_wishlist_path(@wishlist) %>
  <%= button_to "Delete", @wishlist, method: :delete, data: { turbo_confirm: "Are you sure?" } %>
<% end %>

<h3><%= pluralize @wishlist.products_count, "Product" %></h3>
<% @wishlist.wishlist_products.includes(:product).each do %>
  <div>
    <%= link_to it.product.name, it.product %>
    <small>Added <%= l it.created_at, format: :long %></small>
  </div>
<% end %>

最后,让我们在 app/views/layouts/application.html.erb 的导航栏中添加一个链接

    <nav class="navbar">
      <%= link_to "Home", root_path %>
      <% if authenticated? %>
        <%= link_to "Wishlists", wishlists_path %>
        <%= link_to "Settings", settings_root_path %>
        <%= button_to "Log out", session_path, method: :delete %>
      <% else %>
        <%= link_to "Sign Up", sign_up_path %>
        <%= link_to "Login", new_session_path %>
      <% end %>
    </nav>

刷新页面并点击导航栏中的“心愿单”链接,以查看和管理您的心愿单。

4.3. 复制到剪贴板

为了方便分享心愿单,我们可以添加一个使用少量 JavaScript 的“复制到剪贴板”按钮。

Rails 默认包含 Hotwire,因此我们可以使用其 Stimulus 框架为我们的 UI 添加一些轻量级 JavaScript。

首先,让我们在 app/views/wishlists/show.html.erb 中添加一个按钮

<h1><%= @wishlist.name %></h1>
<% if authenticated? && @wishlist.user == Current.user %>
  <%= link_to "Edit", edit_wishlist_path(@wishlist) %>
  <%= button_to "Delete", @wishlist, method: :delete, data: { turbo_confirm: "Are you sure?" } %>
<% end %>

<%= tag.button "Copy to clipboard", data: { controller: :clipboard, action: "clipboard#copy", clipboard_text_value: wishlist_url(@wishlist) } %>

这个按钮有几个数据属性,它们连接到 JavaScript。我们使用 Rails 的 tag 助手来缩短它,它输出以下 HTML

<button data-controller="clipboard" data-action="clipboard#copy" data-clipboard-text-value="/wishlists/1-example-wishlist">
  Copy to clipboard
</button>

这些数据属性有什么作用?让我们逐一分解

  • data-controller 告诉 Stimulus 连接到 clipboard_controller.js
  • data-action 告诉 Stimulus 在点击按钮时调用 clipboard 控制器的 copy() 方法
  • data-clipboard-text-value 告诉 Stimulus 控制器它有一些名为 text 的数据可以使用

app/javascript/controllers/clipboard_controller.js 中创建 Stimulus 控制器

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = { text: String }

  copy() {
    navigator.clipboard.writeText(this.textValue)
  }
}

这个 Stimulus 控制器很短。它做两件事

  • text 注册为一个值,以便我们可以访问它。这是我们要复制到剪贴板的 URL。
  • copy 函数在调用时将 HTML 中的 text 写入剪贴板。

如果你熟悉 JavaScript,你会注意到我们不需要添加任何事件监听器或设置/拆卸此控制器。这由 Stimulus 读取 HTML 中的数据属性自动处理。

要了解更多关于 Stimulus 的信息,请访问 Stimulus 网站。

4.4. 删除产品

用户可能购买了某个产品或对其失去兴趣,并希望将其从心愿单中删除。接下来让我们添加这个功能。

首先,我们将更新心愿单路由以包含嵌套资源。

Rails.application.routes.draw do
  # ...
  resources :products do
    resource :wishlist, only: [ :create ], module: :products
    resources :subscribers, only: [ :create ]
  end
  resource :unsubscribe, only: [ :show ]

  resources :wishlists do
    resources :wishlist_products, only: [ :update, :destroy ], module: :wishlists
  end

然后我们可以更新 app/views/wishlists/show.html.erb 以包含一个“删除”按钮

<h1><%= @wishlist.name %></h1>
<% if authenticated? && @wishlist.user == Current.user %>
  <%= link_to "Edit", edit_wishlist_path(@wishlist) %>
  <%= button_to "Delete", @wishlist, method: :delete, data: { turbo_confirm: "Are you sure?" } %>
<% end %>

<h3><%= pluralize @wishlist.products_count, "Product" %></h3>
<% @wishlist.wishlist_products.includes(:product).each do %>
  <div>
    <%= link_to it.product.name, it.product %>
    <small>Added <%= l it.created_at, format: :long %></small>

    <% if authenticated? && @wishlist.user == Current.user %>
      <%= button_to "Remove", [ @wishlist, it ], method: :delete, data: { turbo_confirm: "Are you sure?" } %>
    <% end %>
  </div>
<% end %>

创建 app/controllers/wishlists/wishlist_products_controller.rb 并添加以下内容

class Wishlists::WishlistProductsController < ApplicationController
  before_action :set_wishlist
  before_action :set_wishlist_product

  def destroy
    @wishlist_product.destroy
    redirect_to @wishlist, notice: "#{@wishlist_product.product.name} removed from wishlist."
  end

  private

  def set_wishlist
    @wishlist = Current.user.wishlists.find_by(id: params[:wishlist_id])
  end

  def set_wishlist_product
    @wishlist_product = @wishlist.wishlist_products.find(params[:id])
  end
end

你现在可以从任何心愿单中删除产品了。试试看吧!

4.5. 将产品移动到另一个心愿单

有了多个心愿单,用户可能希望将产品从一个列表移动到另一个列表。例如,他们可能希望将商品移动到“圣诞节”心愿单中。

app/views/wishlists/show.html.erb 中,添加以下内容

<h1><%= @wishlist.name %></h1>
<% if authenticated? && @wishlist.user == Current.user %>
  <%= link_to "Edit", edit_wishlist_path(@wishlist) %>
  <%= button_to "Delete", @wishlist, method: :delete, data: { turbo_confirm: "Are you sure?" } %>
<% end %>

<h3><%= pluralize @wishlist.products_count, "Product" %></h3>
<% @wishlist.wishlist_products.includes(:product).each do %>
  <div>
    <%= link_to it.product.name, it.product %>
    <small>Added <%= l it.created_at, format: :long %></small>

    <% if authenticated? && @wishlist.user == Current.user %>
      <% if (other_wishlists = Current.user.wishlists.excluding(@wishlist)) && other_wishlists.any? %>
        <%= form_with url: [ @wishlist, it ], method: :patch do |form| %>
          <%= form.collection_select :new_wishlist_id, other_wishlists, :id, :name %>
          <%= form.submit "Move" %>
        <% end %>
      <% end %>

      <%= button_to "Remove", [ @wishlist, it ], method: :delete, data: { turbo_confirm: "Are you sure?" } %>
    <% end %>
  </div>
<% end %>

这会查询其他心愿单,如果存在,则渲染一个表单以将产品移动到所选的心愿单。如果不存在其他心愿单,则不会显示该表单。

为了在控制器中处理这个问题,我们将在 app/controllers/wishlists/wishlist_products_controller.rb 中添加 update 操作

class Wishlists::WishlistProductsController < ApplicationController
  before_action :set_wishlist
  before_action :set_wishlist_product

  def update
    new_wishlist = Current.user.wishlists.find(params[:new_wishlist_id])
    if @wishlist_product.update(wishlist: new_wishlist)
      redirect_to @wishlist, status: :see_other, notice: "#{@wishlist_product.product.name} has been moved to #{new_wishlist.name}"
    else
      redirect_to @wishlist, status: :see_other, alert: "#{@wishlist_product.product.name} is already on #{new_wishlist.name}."
    end
  end

  # ...

此操作从已登录用户的心愿单中查找新的心愿单。然后它尝试更新 @wishlist_product 上的心愿单 ID。如果产品已存在于另一个心愿单中,这可能会失败,在这种情况下我们将显示一个错误。如果没有,我们可以简单地将产品转移到新的心愿单。由于我们不希望用户失去他们当前正在查看的位置,无论哪种情况,我们都重定向回当前的心愿单。

通过创建第二个心愿单并来回移动产品来测试此功能。

5. 将心愿单添加到管理后台

在管理区域查看心愿单将有助于了解哪些产品受欢迎。

首先,让我们将心愿单添加到 config/routes.rb 中的商店命名空间路由

  # Admins Only
  namespace :store do
    resources :products
    resources :users
    resources :wishlists

    root to: redirect("/store/products")
  end

创建 app/controllers/store/wishlists_controller.rb,内容如下

class Store::WishlistsController < Store::BaseController
  def index
    @wishlists = Wishlist.includes(:user)
  end

  def show
    @wishlist = Wishlist.find(params[:id])
  end
end

我们只需要这里的 index 和 show 操作,因为作为管理员,我们不想干预用户的心愿单。

现在让我们为这些操作添加视图。在 app/views/store/wishlists/index.html.erb 中创建

<h1>Wishlists</h1>
<%= render @wishlists %>

然后在 app/views/store/wishlists/_wishlist.html.erb 中创建心愿单局部视图

<div>
  <%= link_to wishlist.name, store_wishlist_path(wishlist) %> by <%= link_to wishlist.user.full_name, store_user_path(wishlist.user) %>
</div>

然后在 app/views/store/wishlists/show.html.erb 中创建 show 视图

<h1><%= @wishlist.name %></h1>
<p>By <%= link_to @wishlist.user.full_name, store_user_path(@wishlist.user) %></p>

<h3><%= pluralize @wishlist.products_count, "Product" %></h3>
<% @wishlist.wishlist_products.includes(:product).each do %>
  <div>
    <%= link_to it.product.name, store_product_path(it.product) %>
    <small>Added <%= l it.created_at, format: :long %></small>
  </div>
<% end %>

最后,将链接添加到侧边栏布局

<%= content_for :content do %>
  <section class="settings">
    <nav>
      <h4>Account Settings</h4>
      <%= link_to "Profile", settings_profile_path %>
      <%= link_to "Email", settings_email_path %>
      <%= link_to "Password", settings_password_path %>
      <%= link_to "Account", settings_user_path %>

      <% if Current.user.admin? %>
        <h4>Store Settings</h4>
        <%= link_to "Products", store_products_path %>
        <%= link_to "Users", store_users_path %>
        <%= link_to "Wishlists", store_products_path %>
      <% end %>
    </nav>

    <div>
      <%= yield %>
    </div>
  </section>
<% end %>

<%= render template: "layouts/application" %>

现在我们可以在管理区域查看心愿单了。

5.1. 过滤心愿单

为了更好地查看管理区域中的数据,拥有过滤器很有帮助。我们可以按用户或按产品过滤心愿单。

更新 app/views/store/wishlists/index.html.erb,添加以下表单

<h1><%= pluralize @wishlists.count, "Wishlist" %></h1>

<%= form_with url: store_wishlists_path, method: :get do |form| %>
  <%= form.collection_select :user_id, User.all, :id, :full_name, selected: params[:user_id], include_blank: "All Users" %>
  <%= form.collection_select :product_id, Product.all, :id, :name, selected: params[:product_id], include_blank: "All Products" %>
  <%= form.submit "Filter" %>
<% end %>

<%= render @wishlists %>

我们已经更新了标题以显示心愿单的总数,这使得在应用过滤器时更容易看到有多少结果匹配。当你提交表单时,Rails 将你选择的过滤器作为查询参数添加到 URL 中。然后,表单在加载页面时读取这些值,以自动重新选择下拉菜单中的相同选项,因此你的选择在提交后仍然可见。由于表单提交到索引操作,因此它可以显示所有心愿单或仅显示过滤后的结果。

为了实现这一点,我们需要在我们的 SQL 查询中通过 Active Record 应用这些过滤器。更新控制器以包含这些过滤器

class Store::WishlistsController < Store::BaseController
  def index
    @wishlists = Wishlist.includes(:user)
    @wishlists = @wishlists.where(user_id: params[:user_id]) if params[:user_id].present?
    @wishlists = @wishlists.includes(:wishlist_products).where(wishlist_products: { product_id: params[:product_id] }) if params[:product_id].present?
  end

  def show
    @wishlist = Wishlist.find(params[:id])
  end
end

Active Record 查询是*惰性求值*的,这意味着 SQL 查询直到您请求结果时才执行。这允许我们的控制器逐步构建查询,并在需要时包含过滤器。

一旦系统中有更多心愿单,您就可以使用过滤器按特定用户、产品或两者的组合来查看心愿单。

5.2. 重构过滤器

我们的控制器因为引入这些过滤器而变得有点混乱。让我们通过在 Wishlist 模型上提取一个方法,将逻辑从控制器中移出。

class Store::WishlistsController < Store::BaseController
  def index
    @wishlists = Wishlist.includes(:user).filter_by(params)
  end

  def show
    @wishlist = Wishlist.find(params[:id])
  end
end

我们将通过定义一个类方法在 Wishlist 模型中实现 filter_by

class Wishlist < ApplicationRecord
  belongs_to :user
  has_many :wishlist_products, dependent: :destroy
  has_many :products, through: :wishlist_products

  to_param :name

  def self.filter_by(params)
    results = all
    results = results.where(user_id: params[:user_id]) if params[:user_id].present?
    results = results.includes(:wishlist_products).where(wishlist_products: {product_id: params[:product_id]}) if params[:product_id].present?
    results
  end
end

filter_by 与我们在控制器中的功能几乎相同,但我们首先调用 all,它返回一个 ActiveRecord::Relation,包含所有记录以及我们可能已应用的所有条件。然后我们应用过滤器并返回结果。

这样的重构意味着控制器变得更整洁,而过滤逻辑现在属于模型,与数据库相关的其他逻辑一起。这遵循了 Rails 中的最佳实践——**胖模型,瘦控制器**原则。

6. 将订阅者添加到管理后台

既然我们在这里,我们也应该在管理后台中添加查看和过滤订阅者的功能。这有助于了解有多少人正在等待产品重新入库。

6.1. 订阅者视图

首先,我们将订阅者路由添加到 store 命名空间

  # Admins Only
  namespace :store do
    resources :products
    resources :users
    resources :wishlists
    resources :subscribers

    root to: redirect("/store/products")
  end

然后,让我们在 app/controllers/store/subscribers_controller.rb 创建控制器

class Store::SubscribersController < Store::BaseController
  before_action :set_subscriber, except: [ :index ]

  def index
    @subscribers = Subscriber.includes(:product).filter_by(params)
  end

  def show
  end

  def destroy
    @subscriber.destroy
    redirect_to store_subscribers_path, notice: "Subscriber has been removed.", status: :see_other
  end

  private
    def set_subscriber
      @subscriber = Subscriber.find(params[:id])
    end
end

我们只在这里实现了 indexshowdestroy 操作。订阅者只会在用户输入他们的电子邮件地址时创建。如果有人联系支持要求取消订阅,我们希望能够轻松地删除他们。

由于这是管理区域,我们也会为订阅者添加过滤器。

app/models/subscriber.rb 中,让我们添加 filter_by 类方法

class Subscriber < ApplicationRecord
  belongs_to :product
  generates_token_for :unsubscribe

  def self.filter_by(params)
    results = all
    results = results.where(product_id: params[:product_id]) if params[:product_id].present?
    results
  end
end

接下来在 app/views/store/subscribers/index.html.erb 中创建索引视图

<h1><%= pluralize "Subscriber", @subscribers.count %></h1>

<%= form_with url: store_subscribers_path, method: :get do |form| %>
  <%= form.collection_select :product_id, Product.all, :id, :name, selected: params[:product_id], include_blank: "All Products" %>
  <%= form.submit "Filter" %>
<% end %>

<%= render @subscribers %>

然后在 app/views/store/subscribers/_subscriber.html.erb 中创建用于显示每个订阅者的文件

<div>
  <%= link_to subscriber.email, store_subscriber_path(subscriber) %> subscribed to <%= link_to subscriber.product.name, store_product_path(subscriber.product) %> on <%= l subscriber.created_at, format: :long %>
</div>

接下来,在 app/views/store/subscribers/show.html.erb 中创建 show 视图以查看单个订阅者

<h1><%= @subscriber.email %></h1>
<p>Subscribed to <%= link_to @subscriber.product.name, store_product_path(@subscriber.product) %> on <%= l @subscriber.created_at, format: :long %></p>

<%= button_to "Remove", store_subscriber_path(@subscriber), method: :delete, data: { turbo_confirm: "Are you sure?" } %>

最后,将链接添加到侧边栏布局

<%= content_for :content do %>
  <section class="settings">
    <nav>
      <h4>Account Settings</h4>
      <%= link_to "Profile", settings_profile_path %>
      <%= link_to "Email", settings_email_path %>
      <%= link_to "Password", settings_password_path %>
      <%= link_to "Account", settings_user_path %>

      <% if Current.user.admin? %>
        <h4>Store Settings</h4>
        <%= link_to "Products", store_products_path %>
        <%= link_to "Users", store_users_path %>
        <%= link_to "Subscribers", store_subscribers_path %>
        <%= link_to "Wishlists", store_products_path %>
      <% end %>
    </nav>

    <div>
      <%= yield %>
    </div>
  </section>
<% end %>

<%= render template: "layouts/application" %>

现在您可以在商店的管理区域查看、过滤和删除订阅者。试试看吧!

现在我们已经添加了过滤器,我们可以在产品详情页添加链接,用于查看特定产品的心愿单和订阅者。

打开 app/views/store/products/show.html.erb 并添加链接

<p><%= link_to "Back", store_products_path %></p>

<section class="product">
  <%= image_tag @product.featured_image if @product.featured_image.attached? %>

  <section class="product-info">
    <% cache @product do %>
      <h1><%= @product.name %></h1>
      <%= @product.description %>
    <% end %>

    <%= link_to "View in Storefront", @product %>
    <%= link_to "Edit", edit_store_product_path(@product) %>
    <%= button_to "Delete", [ :store, @product ], method: :delete, data: { turbo_confirm: "Are you sure?" } %>
  </section>
</section>

<section>
  <%= link_to pluralize(@product.wishlists_count, "wishlist"), store_wishlists_path(product_id: @product) %>
  <%= link_to pluralize(@product.subscribers.count, "subscriber"), store_subscribers_path(product_id: @product) %>
</section>

8. 测试心愿单

让我们为刚刚构建的功能编写一些测试。

8.1. 添加夹具

首先,我们需要更新 test/fixtures/wishlist_products.yml 中的夹具,使其引用我们定义的产品夹具

one:
  product: tshirt
  wishlist: one

two:
  product: tshirt
  wishlist: two

我们还在 test/fixtures/products.yml 中添加了另一个 Product 夹具进行测试

tshirt:
  name: T-Shirt
  inventory_count: 15

shoes:
  name: shoes
  inventory_count: 0

8.2. 测试 filter_by

Wishlist 模型的 filter_by 方法对于确保它正确过滤记录至关重要。

打开 test/models/wishlist_test.rb 并添加此测试以开始

require "test_helper"

class WishlistTest < ActiveSupport::TestCase
  test "filter_by with no filters" do
    assert_equal Wishlist.all, Wishlist.filter_by({})
  end
end

此测试确保在未应用任何过滤器时,filter_by 返回所有记录。

然后运行测试

$ bin/rails test test/models/wishlist_test.rb
Running 1 tests in a single process (parallelization threshold is 50)
Run options: --seed 64578

# Running:

.

Finished in 0.290295s, 3.4448 runs/s, 3.4448 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips

太棒了!接下来,我们需要测试 user_id 过滤器。让我们添加另一个测试

require "test_helper"

class WishlistTest < ActiveSupport::TestCase
  test "filter_by with no filters" do
    assert_equal Wishlist.all, Wishlist.filter_by({})
  end

  test "filter_by with user_id" do
    wishlists = Wishlist.filter_by(user_id: users(:one).id)
    assert_includes wishlists, wishlists(:one)
    assert_not_includes wishlists, wishlists(:two)
  end
end

此测试运行查询并断言返回了该用户的心愿单,但未返回其他用户的心愿单。

让我们再次运行测试文件

$ bin/rails test test/models/wishlist_test.rb
Running 2 tests in a single process (parallelization threshold is 50)
Run options: --seed 48224

# Running:

..

Finished in 0.292714s, 6.8326 runs/s, 17.0815 assertions/s.
2 runs, 5 assertions, 0 failures, 0 errors, 0 skips

完美!两个测试都通过了。

最后,让我们添加一个针对特定产品心愿单的测试。

对于这个测试,我们需要向我们的一个心愿单中添加一个独特的产品,以便可以对其进行过滤。

打开 test/fixtures/wishlist_products.yml 并添加以下内容

one:
  product: tshirt
  wishlist: one

two:
  product: tshirt
  wishlist: two

three:
  product: shoes
  wishlist: two

然后将以下测试添加到 test/models/wishlist_test.rb

require "test_helper"

class WishlistTest < ActiveSupport::TestCase
  test "filter_by with no filters" do
    assert_equal Wishlist.all, Wishlist.filter_by({})
  end

  test "filter_by with user_id" do
    wishlists = Wishlist.filter_by(user_id: users(:one).id)
    assert_includes wishlists, wishlists(:one)
    assert_not_includes wishlists, wishlists(:two)
  end

  test "filter_by with product_id" do
    wishlists = Wishlist.filter_by(product_id: products(:shoes).id)
    assert_includes wishlists, wishlists(:two)
    assert_not_includes wishlists, wishlists(:one)
  end
end

此测试按特定产品进行过滤,并确保返回正确的心愿单,而不返回没有该产品的心愿单。

让我们再次运行此测试文件,确保它们都通过

bin/rails test test/models/wishlist_test.rb
Running 3 tests in a single process (parallelization threshold is 50)
Run options: --seed 27430

# Running:

...

Finished in 0.320054s, 9.3734 runs/s, 28.1203 assertions/s.
3 runs, 9 assertions, 0 failures, 0 errors, 0 skips

8.3. 测试心愿单 CRUD

让我们来编写一些心愿单的集成测试。

创建 test/integration/wishlists_test.rb 并添加一个创建心愿单的测试。

require "test_helper"

class WishlistsTest < ActionDispatch::IntegrationTest
  test "create a wishlist" do
    user = users(:one)
    sign_in_as user
    assert_difference "user.wishlists.count" do
      post wishlists_path, params: { wishlist: { name: "Example" } }
      assert_response :redirect
    end
  end
end

此测试以用户身份登录并发出 POST 请求以创建心愿单。它检查用户的心愿单数量在之前和之后,以确保创建了新记录。它还确认用户被重定向,而不是重新渲染带有错误的表单。

让我们运行这个测试,确保它通过。

$ bin/rails test test/integration/wishlists_test.rb
Running 1 tests in a single process (parallelization threshold is 50)
Run options: --seed 40232

# Running:

.

Finished in 0.603018s, 1.6583 runs/s, 4.9750 assertions/s.
1 runs, 3 assertions, 0 failures, 0 errors, 0 skips

接下来,让我们添加一个删除心愿单的测试。

test "delete a wishlist" do
  user = users(:one)
  sign_in_as user
  assert_difference "user.wishlists.count", -1 do
    delete wishlist_path(user.wishlists.first)
    assert_redirected_to wishlists_path
  end
end

这个测试与创建心愿单类似,但它断言在发出 DELETE 请求后,心愿单的数量会减少一个。

接下来,我们应该测试查看心愿单,从用户查看自己的心愿单开始。

test "view a wishlist" do
  user = users(:one)
  wishlist = user.wishlists.first
  sign_in_as user
  get wishlist_path(wishlist)
  assert_response :success
  assert_select "h1", text: wishlist.name
end

用户也应该能够查看其他用户的心愿单,所以让我们来测试一下

test "view a wishlist as another user" do
  wishlist = wishlists(:two)
  sign_in_as users(:one)
  get wishlist_path(wishlist)
  assert_response :success
  assert_select "h1", text: wishlist.name
end

访客也应该能够查看心愿单

test "view a wishlist as a guest" do
  wishlist = wishlists(:one)
  get wishlist_path(wishlist)
  assert_response :success
  assert_select "h1", text: wishlist.name
end

让我们运行这些测试并确保它们都通过

$ bin/rails test test/integration/wishlists_test.rb
Running 5 tests in a single process (parallelization threshold is 50)
Run options: --seed 43675

# Running:

.....

Finished in 0.645956s, 7.7405 runs/s, 13.9328 assertions/s.
5 runs, 9 assertions, 0 failures, 0 errors, 0 skips

太棒了!

8.4. 测试心愿单产品

接下来,让我们测试心愿单中的产品。最好的起点可能是将产品添加到心愿单。

将以下测试添加到 test/integration/wishlists_test.rb

test "add product to a specific wishlist" do
  sign_in_as users(:one)
  wishlist = wishlists(:one)
  assert_difference "WishlistProduct.count" do
    post product_wishlist_path(products(:shoes)), params: { wishlist_id: wishlist.id }
    assert_redirected_to wishlist
  end
end

此测试断言,当我们发送一个模拟提交“添加到心愿单”表单(包含选定心愿单)的 POST 请求时,会创建一个新的 WishlistProduct 记录。

接下来,让我们测试用户没有任何心愿单的情况。

test "add product when no wishlists" do
  user = users(:one)
  sign_in_as user
  user.wishlists.destroy_all
  assert_difference "Wishlist.count" do
    assert_difference "WishlistProduct.count" do
      post product_wishlist_path(products(:shoes))
    end
  end
end

在此测试中,我们删除用户的所有心愿单,以移除夹具中可能存在的任何心愿单。除了断言创建了新的 WishlistProduct 之外,我们还确保这次创建了新的 Wishlist

我们还应该测试我们不能将产品添加到另一个用户的心愿单中。添加以下测试。

test "cannot add product to another user's wishlist" do
  sign_in_as users(:one)
  assert_no_difference "WishlistProduct.count" do
    post product_wishlist_path(products(:shoes)), params: { wishlist_id: wishlists(:two).id }
    assert_response :not_found
  end
end

在这种情况下,我们以一个用户身份登录,并使用另一个用户心愿单的 ID 进行 POST 请求。为了确保这能正确工作,我们断言没有创建新的 WishlistProduct 记录,并且我们还确保响应是 404 Not Found。

现在,让我们测试在心愿单之间移动产品。

test "move product to another wishlist" do
  user = users(:one)
  sign_in_as user
  wishlist = user.wishlists.first
  wishlist_product = wishlist.wishlist_products.first
  second_wishlist = user.wishlists.create!(name: "Second Wishlist")
  patch wishlist_wishlist_product_path(wishlist, wishlist_product), params: { new_wishlist_id: second_wishlist.id }
  assert_equal second_wishlist, wishlist_product.reload.wishlist
end

此测试比其他测试有更多的设置。它创建了第二个心愿单以移动产品。由于此操作更新 WishlistProduct 记录的 wishlist_id 列,因此我们将其保存到一个变量中,并断言在请求完成后它会发生变化。

我们必须调用 wishlist_product.reload,因为内存中的记录副本不知道请求期间发生的更改。这会从数据库中重新加载记录,以便我们可以看到新值。

接下来,让我们测试将产品移动到已包含该产品的心愿单。在这种情况下,我们应该收到错误消息,并且 WishlistProduct 不应有任何更改。

  test "cannot move product to a wishlist that already contains product" do
    user = users(:one)
    sign_in_as user
    wishlist = user.wishlists.first
    wishlist_product = wishlist.wishlist_products.first
    second_wishlist = user.wishlists.create!(name: "Second")
    second_wishlist.wishlist_products.create(product_id: wishlist_product.product_id)
    patch wishlist_wishlist_product_path(wishlist, wishlist_product), params: { new_wishlist_id: second_wishlist.id }
    assert_equal "T-Shirt is already on Second Wishlist.", flash[:alert]
    assert_equal wishlist, wishlist_product.reload.wishlist
  end

此测试使用针对 flash[:alert] 的断言来检查错误消息。它还重新加载 wishlist_product 以断言心愿单未更改。

最后,我们应该添加一个测试,以确保用户不能将产品移动到另一个用户的心愿单。

  test "cannot move product to another user's wishlist" do
    user = users(:one)
    sign_in_as user
    wishlist = user.wishlists.first
    wishlist_product = wishlist.wishlist_products.first
    patch wishlist_wishlist_product_path(wishlist, wishlist_product), params: { new_wishlist_id: wishlists(:two).id }
    assert_response :not_found
    assert_equal wishlist, wishlist_product.reload.wishlist
  end

在这种情况下,我们断言响应是 404 Not Found,这表明我们安全地将 new_wishlist_id 范围限定为当前用户。

它还断言心愿单没有改变,就像之前的测试一样。

好了,让我们运行这套完整的测试,再次检查它们是否都通过。

$ bin/rails test test/integration/wishlists_test.rb
Running 11 tests in a single process (parallelization threshold is 50)
Run options: --seed 65170

# Running:

...........

Finished in 1.084135s, 10.1463 runs/s, 23.0599 assertions/s.
11 runs, 25 assertions, 0 failures, 0 errors, 0 skips

太棒了!我们的测试都通过了。

9. 部署到生产环境

由于我们之前在 入门指南中设置了 Kamal,我们只需将代码更改推送到我们的 Git 仓库并运行

$ bin/kamal deploy

10. 下一步

您的电子商务商店现在拥有心愿单,并且管理区域通过心愿单和订阅者的过滤功能得到了改进。

以下是一些在此基础上继续构建的想法

  • 添加产品评论
  • 编写更多测试
  • 完成将应用程序翻译成另一种语言
  • 为产品图片添加轮播
  • 用 CSS 改进设计
  • 添加支付功能以购买产品

返回所有教程



回到顶部