1. 简介
电子商务商店通常有用于分享产品的心愿单。客户可以使用心愿单来跟踪他们想购买的产品,或者与朋友和家人分享以获取礼物创意。
让我们开始吧!
2. 心愿单模型
我们的电子商务商店有产品和用户,这些我们已经在之前的教程中构建好了。这些是我们构建心愿单所需的基础。每个心愿单都属于一个用户,并包含一个产品列表。
让我们从创建 Wishlist 模型开始。
$ bin/rails generate model Wishlist user:belongs_to name products_count:integer
这个模型有 3 个属性
user:belongs_to将Wishlist与拥有它的User关联起来name我们也将用于友好的 URLproducts_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 实例方法,该方法返回一个由 id 和 name 通过连字符连接组成的 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.jsdata-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
我们只在这里实现了 index、show 和 destroy 操作。订阅者只会在用户输入他们的电子邮件地址时创建。如果有人联系支持要求取消订阅,我们希望能够轻松地删除他们。
由于这是管理区域,我们也会为订阅者添加过滤器。
在 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" %>
现在您可以在商店的管理区域查看、过滤和删除订阅者。试试看吧!
7. 为产品添加链接
现在我们已经添加了过滤器,我们可以在产品详情页添加链接,用于查看特定产品的心愿单和订阅者。
打开 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 改进设计
- 添加支付功能以购买产品