1. Introduction
E-commerce stores often have wishlists for sharing products. Customers can use wishlists to keep track of products they'd like to buy or share them with friends and family for gift ideas.
Let's get started!
2. Wishlist Models
Our e-commerce store has products and users that we already built in the previous tutorials. These are the foundations we need to build Wishlists. Each wishlist belongs to a user and contains a list of products.
Let's start by creating the Wishlist
model.
$ bin/rails generate model Wishlist user:belongs_to name products_count:integer
This model has 3 attributes:
user:belongs_to
which associates theWishlist
with theUser
who owns itname
which we'll also use for friendly URLsproducts_count
for the counter cache to count how many products are on the Wishlist
To associate a Wishlist
with multiple Products
, we need to add a table to
join them.
$ bin/rails generate model WishlistProduct product:belongs_to wishlist:belongs_to
We don't want the same Product
to be on a Wishlist
multiple times, so let's
add an index to the migration that was just created:
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
Finally, let's add a counter to the Product
model to keep track of how many
Wishlists
the product is on.
$ bin/rails generate migration AddWishlistsCountToProducts wishlists_count:integer
2.1. Default Counter Cache Values
Before we run these new migrations, let's set a default value for the counter cache columns so that all existing records start with a count of zero instead of NULL.
Open the db/migrate/<timestamp>_create_wishlists.rb
migration and add the
default option:
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
Then open db/migrate/<timestamp>_add_wishlists_count_to_products.rb
and add a
default here too:
class AddWishlistsCountToProducts < ActiveRecord::Migration[8.0]
def change
add_column :products, :wishlists_count, :integer, default: 0
end
end
Now let's run the migrations:
$ bin/rails db:migrate
2.2. Associations & Counter Caches
Now that our database tables are created, let's update our models in Rails to include these new associations.
In app/models/user.rb
, add the following:
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
has_many :wishlists, dependent: :destroy
# ...
We set dependent: :destroy
on the wishlists
association so when a User is
deleted, their wishlists are deleted too.
Then in app/models/product.rb
, add:
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
We added two associations to Product
. First, we associate the Product
model
with the WishlistProduct
join table. Using this join table, our second
association tells Rails that a Product
is a part of many Wishlists
through
the same WishlistProduct
join table. From a Product
record, we can directly
access the Wishlists
and Rails will know to automatically JOIN
the tables in
SQL queries.
We also set wishlist_products
as dependent: :destroy
. When a Product
is
destroyed, it will be automatically removed from any Wishlists.
A counter cache stores the number of associated records to avoid running a separate query each time the count is needed. So in app/models/wishlist.rb
, let's update both associations to enable counter
caching:
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
We've specified a column name to update on the associated models. For the
Product
model, we want to use the wishlists_count
column and for Wishlist
we
want to use products_count
. These counter caches update anytime a
WishlistProduct
is created or destroyed.
The uniqueness
validation also tells Rails to check if a product is already on
the wishlist. This is paired with the unique index on the wishlist_product table
so that it's also validated at the database level.
Finally, let's update app/models/wishlist.rb
with it's associations:
class Wishlist < ApplicationRecord
belongs_to :user
has_many :wishlist_products, dependent: :destroy
has_many :products, through: :wishlist_products
end
Just like with Product
, wishlist_products
uses the dependent: :destroy
option to automatically remove join table records when a Wishlist is deleted.
2.3. Friendly URLs
Wishlists are often shared with friends and family. By default, the ID in the
URL for a Wishlist
is a simple Integer. This means we can't easily look at the
URL to determine which Wishlist
it's for.
Active Record has a to_param
class method that can be used for generating more descriptive
URLs. Let's try it out in our model:
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
This will create a to_param
instance method that returns a String for the URL param made up of the id
and name
joined by
hyphens. name
is made URL safe by using
squish
to
clean up whitespace and
parameterize
to replace special characters.
Let's test this in the Rails console:
$ bin/rails console
Then create a Wishlist
for your User
in the database:
store(dev)> user = User.first
store(dev)> wishlist = user.wishlists.create!(name: "Example Wishlist")
store(dev)> wishlist.to_param
=> "1-example-wishlist"
Perfect!
Now let's try finding this record using this param:
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">
It worked! But how? Didn't we have to use Integers to find records?
The way we're using to_param
takes advantage of how Ruby converts Strings to
Integers. Let's convert that param to an integer using to_i
in the console:
store(dev)> "1-example-wishlist".to_i
=> 1
Ruby parses the String until it finds a character that isn't a valid number. In
this case, it stops at the first hyphen. Then Ruby converts the String of "1"
into an Integer and returns 1
. This makes to_param
work seamlessly when
prefixing the ID at the beginning.
Now that we understand how this works, let's replace our to_param
method with
a call to the class method shortcut.
class Wishlist < ApplicationRecord
belongs_to :user
has_many :wishlist_products, dependent: :destroy
has_many :products, through: :wishlist_products
to_param :name
end
The
to_param
class method defines an instance method with the same name. The argument is the
method name to be called for generating the param. We're telling it to use the
name
attribute to generate the param.
One additional thing to_param
does is truncate values longer than 20
characters word by word.
Let's reload our code in the Rails console and test out a long Wishlist
name.
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"
You can see that the name was truncated to the closest word to 20 characters.
Alright, close the Rails console and let's start implementing wishlists in the UI.
3. Adding Products To Wishlists
The first place a user will probably use wishlists is on the Product
show page.
They'll likely be browsing products and want to save one for later. Let's begin
by building that first.
3.1. Add To Wishlist Form
Start in config/routes.rb
by adding the route for this form to submit to:
resources :products do
resource :wishlist, only: [ :create ], module: :products
resources :subscribers, only: [ :create ]
end
We're using a singular resource for this route since we won't necessarily know
the Wishlist ID ahead of time. We're also using module: :products
to scope
this controller to the Products
namespace.
In app/views/products/show.html.erb
, add the following to render a new
wishlist partial:
<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>
Then create app/views/products/_wishlist.html.erb
with the following:
<% 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 %>
If a user is not logged in, they'll see a link to sign up. Logged in users will see a form to select a wishlist and add the product to it.
Next, create the controller to handle this form in
app/controllers/products/wishlists_controller.rb
with the following:
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
Since we're in a nested resource route, we find the Product
using the
:product_id
param.
The create
action is also simpler than normal. If a product is already on the
wishlist, the wishlist_product
record will fail to create but we don't need to
notify the user of this error so we can redirect to the wishlist in either case.
Now, log in as the user we created a wishlist for earlier and try adding a product to the wishlist.
3.2. Default Wishlist
This works fine since we created a wishlist in the Rails console, but what happens when the user doesn't have any wishlists?
Run the following to delete all wishlists in the database:
$ bin/rails runner "Wishlist.destroy_all"
Try visiting a product and adding it to a wishlist now.
The first problem is the select box will be empty. The form will not submit a
wishlist_id
param to the server and that will cause Active Record to raise an
error.
ActiveRecord::RecordNotFound (Couldn't find Wishlist without an ID):
app/controllers/products/wishlists_controller.rb:16:in 'Products::WishlistsController#set_wishlist'
In this case, we should automatically create a wishlist if the user doesn't have any. This has the added bonus of slowly introducing the user to wishlists.
Update set_wishlist
in the controller to find or create a 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
To improve our form, let's hide the select box if the user doesn't have any
wishlists. Update app/views/products/_wishlist.html.erb
with the following:
<% 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. Managing Wishlists
Next, we need to be able to view and manage our wishlists.
4.1. Wishlists Controller
Start by adding a route for wishlists at the top level:
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
Then we can add the controller at app/controllers/wishlists_controller.rb
with
the following:
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
This is a very standard controller with a couple important changes:
- Actions are scoped to
Current.user.wishlists
so only the owner can create, update, and delete their own wishlists show
is publicly accessible so wishlists can be shared and viewed by anyone
4.2. Wishlist Views
Create the index view at app/views/wishlists/index.html.erb
:
<h1>Your Wishlists</h1>
<%= link_to "Create a wishlist", new_wishlist_path %>
<%= render @wishlists %>
This renders the _wishlist
partial so let's create that at
app/views/wishlists/_wishlist.html.erb
:
<div>
<%= link_to wishlist.name, wishlist %>
</div>
Next let's create the new
view at app/views/wishlists/new.html.erb
:
<h1>New Wishlist</h1>
<%= render "form", locals: { wishlist: @wishlist } %>
And the edit
view at app/views/wishlists/edit.html.erb
:
<h1>Edit Wishlist</h1>
<%= render "form", locals: { wishlist: @wishlist } %>
Along with the _form
partial at app/views/wishlists/_form.html.erb
:
<%= 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 %>
Create show
next at 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>
</div>
<% end %>
Lastly, let's add a link to the navbar in
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>
Refresh the page and click the "Wishlists" link in the navbar to view and manage your wishlists.
4.3. Copy To Clipboard
To make sharing wishlists easier, we can add a “Copy to Clipboard” button that uses a small amount of JavaScript.
Rails includes Hotwire by default, so we can use its Stimulus framework to add some lightweight JavaScript to our UI.
First, let's add a button to 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) } %>
This button has several data attributes that wire up to the JavaScript. We're
using the Rails tag
helper to make this shorter which outputs the following
HTML:
<button data-controller="clipboard" data-action="clipboard#copy" data-clipboard-text-value="/wishlists/1-example-wishlist">
Copy to clipboard
</button>
What do these data attributes do? Let's break down each one:
data-controller
tells Stimulus to connect toclipboard_controller.js
data-action
tells Stimulus to call theclipboard
controller'scopy()
method when the button is clickeddata-clipboard-text-value
tells the Stimulus controller it has some data calledtext
that it can use
Create the Stimulus controller at
app/javascript/controllers/clipboard_controller.js
:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { text: String }
copy() {
navigator.clipboard.writeText(this.textValue)
}
}
This Stimulus controller is short. It does two things:
- Registers
text
as a value so we can access it. This is the URL we want to copy to the clipboard. - The
copy
function writes thetext
from the HTML to the clipboard when called.
If you're familiar with JavaScript, you'll notice we didn't have to add any event listeners or setup & teardown this controller. That's handled automatically by Stimulus reading the data attributes in our HTML.
To learn more about Stimulus, check out the Stimulus website.
4.4. Removing Products
A user may purchase or lose interest in a product and want to remove it from their wishlist. Let's add that feature next.
First we'll update the wishlists route to contain a nested resource.
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
Then we can update app/views/wishlists/show.html.erb
to include a "Remove"
button:
<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 %>
Create app/controllers/wishlists/wishlist_products_controller.rb
and add the
following:
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
You can now remove products from any wishlist. Try it out!
4.5. Moving Products To Another Wishlist
With multiple wishlists, users may want to move a product from one list to another. For example, they might want to move items into a "Christmas" wishlist.
In app/views/wishlists/show.html.erb
, add the following:
<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 %>
This queries for other wishlists and, if present, renders a form to move a product to the selected wishlist. If no other wishlists exist, the form will not be displayed.
To handle this in the controller, we'll add the update
action to
app/controllers/wishlists/wishlist_products_controller.rb
:
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
# ...
This action looks up the new wishlist from the logged in user's wishlists. It
then tries to update the wishlist ID on @wishlist_product
. This could fail if
the product already exists on the other wishlist so we'll display an error in
that case. If not, we can simply transfer the product to the new wishlist. Since
we don't want the user to lose their place, we redirect back to the current
wishlist they're viewing in either case.
Test this out by creating a second wishlist and moving a product back and forth.
5. Adding Wishlists To Admin
Viewing wishlists in the admin area will be helpful to get an idea of which products are popular.
To start, let's add wishlists to the store namespace routes in
config/routes.rb
:
# Admins Only
namespace :store do
resources :products
resources :users
resources :wishlists
root to: redirect("/store/products")
end
Create app/controllers/store/wishlists_controller.rb
with:
class Store::WishlistsController < Store::BaseController
def index
@wishlists = Wishlist.includes(:user)
end
def show
@wishlist = Wishlist.find(params[:id])
end
end
We only need the index and show actions here because as admins, we don't want to mess with user's wishlists.
Now let's add the views for these actions.
Create app/views/store/wishlists/index.html.erb
with:
<h1>Wishlists</h1>
<%= render @wishlists %>
Then create the wishlist partial in
app/views/store/wishlists/_wishlist.html.erb
with:
<div>
<%= link_to wishlist.name, store_wishlist_path(wishlist) %> by <%= link_to wishlist.user.full_name, store_user_path(wishlist.user) %>
</div>
Then create the show view at app/views/store/wishlists/show.html.erb
with:
<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 %>
Lastly, add the link to the sidebar layout:
<%= 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" %>
Now we can view wishlists in the admin area.
5.1. Filtering Wishlists
To get a better look at data in the admin area, it's helpful to have filters. We can filter wishlists by user or by product.
Update app/views/store/wishlists/index.html.erb
by adding the following form:
<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 %>
We've updated the header to show the total number of wishlists, which makes it easier to see how many results match when a filter is applied. When you submit the form, Rails adds your selected filters to the URL as query params. The form then reads those values when loading the page to automatically re-select the same options in the dropdowns, so your choices stay visible after submitting. Since the form submits to the index action, so it can display either all wishlists or just the filtered results.
To make this work, we need to apply these filters in our SQL query with Active Record. Update the controller to include these filters:
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 queries are lazy evaluated which means SQL queries aren't executed until you ask for the results. This allows our controller to build up the query step-by-step and include filters if needed.
Once you have more wishlists in the system, you can use the filters to view wishlists by a specific user, product, or a combination of both.
5.2. Refactoring Filters
Our controller has gotten a bit messy by introducing these filters. Let's move
our logic out of the controller by extracting a method on the Wishlist
model.
class Store::WishlistsController < Store::BaseController
def index
@wishlists = Wishlist.includes(:user).filter_by(params)
end
def show
@wishlist = Wishlist.find(params[:id])
end
end
We'll implement filter_by
in the Wishlist
model by defining a class method.
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
is almost the same as what we had in the controller, but we start by
calling
all
which returns an ActiveRecord::Relation
for all the records including any
conditions we may have already applied. Then we apply the filters and return the
results.
Refactoring like this means the controller becomes cleaner, while the filtering logic now lives in the model where it belongs, alongside other database-related logic. This follows the Fat Model, Skinny Controller principle, a best practice in Rails.
6. Adding Subscribers To Admin
While we're here, we should also add the ability to view and filter subscribers in the admin too. This is helpful to know how many people are waiting for a product to go back in stock.
6.1. Subscriber Views
First, we'll add the subscribers route to the store
namespace:
# Admins Only
namespace :store do
resources :products
resources :users
resources :wishlists
resources :subscribers
root to: redirect("/store/products")
end
Then, let's create the controller at
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
We've only implemented index
, show
, and destroy
actions here. Subscribers
will only be created when a user enters their email address. If someone contacts
support asking to unsubscribe them, we want to be able to remove them easily.
Since this is the admin area, we will want to add filters to subscribers too.
In app/models/subscriber.rb
, let's add the filter_by
class method:
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
Let's create the index view next at
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 %>
Then create app/views/store/subscribers/_subscriber.html.erb
for displaying
each subscriber:
<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>
Next, create app/views/store/subscribers/show.html.erb
to view an individual
subscriber:
<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?" } %>
Finally, add the link to the sidebar layout:
<%= 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" %>
Now you can view, filter, and remove subscribers in the store's admin area. Try it out!
7. Adding Links To Products
Now that we've added filters, we can add links to the Product show page for viewing wishlists and subscribers for a specific product.
Open app/views/store/products/show.html.erb
and add the links:
<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. Testing Wishlists
Let's write some tests for the functionality we just built.
8.1. Adding Fixtures
First, we need to update the fixtures in test/fixtures/wishlist_products.yml
so they refer to the product fixtures we have defined:
one:
product: tshirt
wishlist: one
two:
product: tshirt
wishlist: two
Let's also add another Product
fixture in test/fixtures/products.yml
to test
with:
tshirt:
name: T-Shirt
inventory_count: 15
shoes:
name: shoes
inventory_count: 0
8.2. Testing filter_by
The Wishlist
model's filter_by
method is important to ensure it's filtering
records correctly.
Open test/models/wishlist_test.rb
and add this test to start:
require "test_helper"
class WishlistTest < ActiveSupport::TestCase
test "filter_by with no filters" do
assert_equal Wishlist.all, Wishlist.filter_by({})
end
end
This test ensures that filter_by
returns all records when no filters are
applied.
Then run the test:
$ 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
Great! Next, we need to test the user_id
filter. Let's add another test:
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
This test runs the query and asserts the wishlist for the user is returned but not wishlists for another user.
Let's run the test file again:
$ 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
Perfect! Both tests are passing.
Finally, let's add a test for wishlists with a specific product.
For this test, we need to add a unique product to one of our wishlists so it can be filtered.
Open test/fixtures/wishlist_products.yml
and add the following:
one:
product: tshirt
wishlist: one
two:
product: tshirt
wishlist: two
three:
product: shoes
wishlist: two
Then add the following test to 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
This test filters by a specific product and ensures the correct wishlist is returned and wishlists without that product are not.
Let's run this test file again to ensure they are all passing:
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. Testing Wishlist CRUD
Let's walk through writing some integration tests for wishlists.
Create test/integration/wishlists_test.rb
and add a test for creating a
wishlist.
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
This test logs in as a user and makes a POST request to create a wishlist. It checks the user's wishlists count before and after to ensure a new record was created. It also confirms the user is redirected instead of re-rendering the form with errors.
Let's run this test and make sure it passes.
$ 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
Next, let's add a test for deleting a wishlist.
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
This test is similar to creating wishlists, but it asserts that there is one less wishlist after making the DELETE request.
Next, we should test viewing wishlists, starting with a user viewing their own wishlist.
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
A user should also be able to view other user's wishlists, so let's test that:
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
And guests should be able to view wishlists too:
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
Let's run these tests and make sure they all pass:
$ 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
Excellent!
8.4. Testing Wishlist Products
Next, let's test products in wishlists. The best place to start is probably adding a product to a wishlist.
Add the following test to 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
This test asserts that a new WishlistProduct
record is created when we send a
POST request that simulates submitting the "Add to wishlist" form with a
selected wishlist.
Next, let's test the case where a user has no wishlists.
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
In this test, we delete all the user's wishlists to remove any wishlists that
may be present from fixtures. In addition to asserting a new WishlistProduct
was created, we also make sure a new Wishlist
was created this time.
We should also test that we can't add products to another user's wishlist. Add the following test.
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
In this case, we sign in as one user and POST
with the ID of a wishlist from
another user. To ensure this is working correctly, we assert that no new
WishlistProduct
records were created and we also make sure the response was a
404 Not Found.
Now, let's test moving products between wishlists.
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
This test has a bit more setup than the others. It creates a second wishlist to
move the product to. Since this action updates the wishlist_id
column of the
WishlistProduct
record, we save it to a variable and assert that it changes
after the request completes.
We have to call wishlist_product.reload
since the copy of the record in memory
is unaware of changes that happened during the request. This reloads the record
from the database so we can see the new values.
Next, let's test moving a product to a wishlist that already contains the
product. In this case, we should get an error message and the WishlistProduct
should have no changes.
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
This test uses an assertion against flash[:alert]
to check for the error
message. It also reloads wishlist_product
to assert that the wishlist has not
changed.
Finally, we should add a test to ensure a user cannot move a product to another user's wishlist.
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
In this case, we assert that the response was a 404 Not Found which shows that
we safely scoped the new_wishlist_id
to the current user.
It also asserts that the wishlist did not change, just like the previous test.
Alright, let's run this full set of tests to double check they all pass.
$ 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
Fantastic! Our tests are all passing.
9. Deploying To Production
Since we previously setup Kamal in the Getting Started Guide, we just need to push our code changes to our Git repository and run:
$ bin/kamal deploy
10. What's Next
Your e-commerce store now has Wishlists and an improved admin area with filtering of Wishlists and Subscribers.
Here are a few ideas to build on to this:
- Add product reviews
- Write more tests
- Add payments to buy products