Andy from Webcrunch

Subscribe for email updates:

Portrait of Andy Leverenz
Andy Leverenz

March 12, 2018

Last updated November 5, 2023

Let's Build: With Ruby on Rails - eCommerce Music Shop

Welcome to the next installment of my Let's Build series featuring Ruby on Rails. In this span of six videos, I'll walk you through the concept of building an eCommerce music shop in a Rails environment.

If you have followed my previous builds, I stem some ideas from those as well as establish some new ideas and techniques. Much of what is discussed I've covered before but there are some new concepts such as database seeding and session parameters I dive into in this series.

Check out the previous series:

Source Code

I feel to really grasp the Ruby on Rails framework the best way is to start writing code even if you don't quite understand it yet.

Download the source code

What's the app?

This series will involve creating a small start to an e-commerce application with user authentication. The application of which I call "Flanger" will revolve around selling and purchasing musical instruments.

The app will use a series of models to formulate both the cart and items of which are for sale. Along the way, we will craft some handy helpers that perform calculations to total the items within our cart as well as remember who a user is if that user adds an item to a cart without being logged in.

A user who has an account can post instruments for sale.

What won't we be covering?

Building a complete e-commerce application is quite a challenge. Often times you need user authentication, tons of options for listing items for sale, a solution for shopping carts, and of course payment options. Accepting payments is a topic I plan to cover in the future but for now, I am omitting this from the app for the sake of time and headache! If you would like to extend this app further you are more than welcome to. I personally use Stripe on other apps and have found great success with integrating it.

Part 2

Part 3

Part 4

Part 5

Part 6

Gems

Our Gemfile is relatively familiar if you happen to have gone through my previous builds. The biggest gem to note is the carrier wave gem of which I also used on the Dribbble clone published a while back.

...
gem 'bulma-rails', '~> 0.6.1'
gem 'simple_form', '~> 3.5'
gem 'devise', '~> 4.4'
gem 'gravatar_image_tag', '~> 1.2'
gem 'carrierwave'
gem 'mini_magick'

group :development, :test do
  ...
  gem 'better_errors', '~> 2.4'
  gem 'guard', '~> 2.14', '>= 2.14.1'
  gem 'guard-livereload', '~> 2.5', '>= 2.5.2'
end

Kicking things off

Instruments

Our main model within the app will be the Instrument model. It will feature all the details and product specs related to a given instrument for sale. To kick things off I walk you through some general setup followed by scaffolding an Instrument model which looks like the following:

$ rails g scaffold Instrument brand:string model:string description:text condition:string finish:string title:string price:decimal --no-stylesheets --no-javascripts

This long one-liner creates our Instrument model and it's associated parameters. Note the price is a decimal datatype. This can also be an integer or even a string depending on how you want to work with the data. After running the scaffold be sure to modify the price column to match the following for best results.

class CreateInstruments < ActiveRecord::Migration[5.1]
  def change
    create_table :instruments do |t|
      t.string :brand
      t.string :model
      t.text :description
      t.string :condition
      t.string :finish
      t.string :title
      # could be an integer if you want, here we set a precision, scale, and default starting point for the decimal
      t.decimal :price, precision: 5, scale: 2, default: 0

      t.timestamps
    end
  end
end

Also, note the two flags we passed —no-stylesheets —no-javascripts. Rails is accommodating when running any type of generation. Here we told rails to forget adding stylesheets and javascript files. There are a ton more flags you can pass. To find out what those are just run this:

$ rails g scaffold

Cart

All eCommerce apps need some sort of Cart. Within our cart, we also need a space for what I end up calling "line items". To create the cart we simply need to define a new model.

$ rails g scaffold Cart --no-stylesheets --no-javascripts

We needn't specify and columns here as well be using the cart as a general restful model.

Concerns

Concerns are similar to helpers of which you can include in controllers throughout the app. We need to know a given user browsing session history to properly associate their session with a given cart. Add the following to models/concerns/current_cart.rb.

# models/concerns/current_cart.rb

module CurrentCart

  private

    def set_cart
      @cart = Cart.find(session[:cart_id])
    rescue ActiveRecord::RecordNotFound
      @cart = Cart.create
      session[:cart_id] = @cart.id
    end

end

Here we find the given session's cart id if it exists. If not we make use of some handy rescue patterns built intro rails which keeps the app from toppling over. If a cart_id isn't found we create one and associate it's id to the user's session. This ultimately allows us to reference any type of user without needing them to create some sort of account or sign in anywhere first. Pretty cool eh?

Connecting Instruments to Carts

To get our Instruments to talk to the Cart we need a model in between. For this, I create a new model called LineItem. This generation is pretty smart as in a few keystrokes we can engage the instrument model to reference a given line item as well as belong to a cart. This ultimately adds indexes to the table which makes them know about each other.

$ rails g scaffold LineItem instrument:references cart:belongs_to
$ rails db:migrate

This generation creates quite a few files. The most notable one includes the model of course.

# models/line_item.rb 

class LineItem < ApplicationRecord
  belongs_to :instrument
  belongs_to :cart
end

We need a way to find the quantity of line items in a given cart. Let's add a migration that does just that.

$ rails g migration add_quantity_to_line_items quantity:integer, default: 1

Notice the quantity is an integer with a default value of 1. This is intentional so that a user who adds one item to a cart will see that there is indeed one item in the quantity field rather than 0. Traditional programming uses integers that start at zero. This is a common gotcha in programming that screws many developers including yours truly all too often!

class AddQuantityToLineItems < ActiveRecord::Migration[5.1]
  def change
    add_column :line_items, :quantity, :integer, default: 1
  end
end

Be sure to run the following:

$ rails db:migrate

Making the models communicate

With the line item model, all square let's get the Cart model and Instrument model up to speed.

# models/cart.rb

class Cart < ApplicationRecord
  has_many :line_items, dependent: :destroy

  def add_instrument(instrument)
    current_item = line_items.find_by(instrument_id: instrument.id)

    if current_item
      current_item.increment(:quantity)
    else
      current_item = line_items.build(instrument_id: instrument.id)
    end
    current_item
  end

  def total_price
    line_items.to_a.sum { |item| item.total_price }
  end

end

Below is the instrument model. You'll see quite a few settings and validations here. Feel free to tweak these to your own liking.

# models/instrument.rb
class Instrument < ApplicationRecord  
  before_destroy :not_referenced_by_any_line_item
  mount_uploader :image, ImageUploader  # carrierwave support for our image column
  serialize :image, JSON # If you use SQLite, add this line.
  belongs_to :user, optional: true

  validates :title, :brand, :price, :model, presence: true
  validates :description, length: { maximum: 1000, too_long: "%{count} characters is the maximum allowed" }
  validates :title, length: { maximum: 140, too_long: "%{count} characters is the maximum allowed" }
  validates :price, numericality: { only_integer: true }, length: { maximum: 7 }

  BRAND = %w{ Fender Gibson Epiphone ESP Martin Dean Taylor Jackson PRS  Ibanez Charvel Washburn }
  FINISH = %w{ Black White Navy Blue Red Clear Satin Yellow Seafoam }
  CONDITION = %w{ New Excellent Mint Used Fair Poor }

  has_many :line_items

  private

   def not_referenced_by_any_line_item
    unless line_items.empty?
      errors.add(:base, 'Line items present')
      throw :abort
    end
   end

end

Line Item model

# app/models/line_item.rb

class LineItem < ApplicationRecord
  belongs_to :instrument
  belongs_to :cart

  def total_price
    instrument.price.to_i * quantity.to_i
  end
end

User model

# app/models/user.rb

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :trackable, :validatable
  has_many :instruments
end

Controllers!

The line items controller does a bit of magic to associate Instruments with a cart. We hook into an instrument_id parameter which was added when we did the line item scaffold. We pay most attention to the create and destroy actions here.

# app/controllers/line_items_controller.rb

class LineItemsController < ApplicationController
  include CurrentCart
  before_action :set_line_item, only: [:show, :edit, :update, :destroy]
  before_action :set_cart, only: [:create]

  # GET /line_items
  # GET /line_items.json
  def index
    @line_items = LineItem.all
  end

  # GET /line_items/1
  # GET /line_items/1.json
  def show
  end

  # GET /line_items/new
  def new
    @line_item = LineItem.new
  end

  # GET /line_items/1/edit
  def edit
  end

  # POST /line_items
  # POST /line_items.json
def create
    @instrument = Instrument.find(params[:instrument_id])
    @line_item = @cart.add_instrument(@instrument)

    respond_to do |format|
      if @line_item.save
        format.html { redirect_to @line_item.cart, notice: 'Line item was successfully created.' }
        format.json { render :show, status: :created, location: @line_item }
      else
        format.html { render :new }
        format.json { render json: @line_item.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /line_items/1
  # PATCH/PUT /line_items/1.json
  def update
    respond_to do |format|
      if @line_item.update(line_item_params)
        format.html { redirect_to @line_item, notice: 'Line item was successfully updated.' }
        format.json { render :show, status: :ok, location: @line_item }
      else
        format.html { render :edit }
        format.json { render json: @line_item.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /line_items/1
  # DELETE /line_items/1.json
  def destroy
    @line_item.destroy
    respond_to do |format|
      format.html { redirect_to line_items_url, notice: 'Line item was successfully destroyed.' }
      format.json { head :no_content }
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_line_item
      @line_item = LineItem.find(params[:id])
    end

    # Never trust parameters from the scary internet, only allow the whitelist through.
    def line_item_params
      params.require(:line_item).permit(:instrument_id)
    end
end

The cart controller is quite similar to the line_items_controller.rb file where we pay most attention to the create and destroy actions. The final code is below:

# app/controllers/carts_controller.rb

class CartsController < ApplicationController
  rescue_from ActiveRecord::RecordNotFound, with: :invalid_cart
  before_action :set_cart, only: [:show, :edit, :update, :destroy]

  # GET /carts
  # GET /carts.json
  def index
    @carts = Cart.all
  end

  # GET /carts/1
  # GET /carts/1.json
  def show
  end

  # GET /carts/new
  def new
    @cart = Cart.new
  end

  # GET /carts/1/edit
  def edit
  end

  # POST /carts
  # POST /carts.json
  def create
    @cart = Cart.new(cart_params)

    respond_to do |format|
      if @cart.save
        format.html { redirect_to @cart, notice: 'Cart was successfully created.' }
        format.json { render :show, status: :created, location: @cart }
      else
        format.html { render :new }
        format.json { render json: @cart.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /carts/1
  # PATCH/PUT /carts/1.json
  def update
    respond_to do |format|
      if @cart.update(cart_params)
        format.html { redirect_to @cart, notice: 'Cart was successfully updated.' }
        format.json { render :show, status: :ok, location: @cart }
      else
        format.html { render :edit }
        format.json { render json: @cart.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /carts/1
  # DELETE /carts/1.json
  def destroy
    @cart.destroy if @cart.id == session[:cart_id]
    session[:cart_id] = nil
    respond_to do |format|
      format.html { redirect_to root_path, notice: 'Cart was successfully destroyed.' }
      format.json { head :no_content }
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_cart
      @cart = Cart.find(params[:id])
    end

    # Never trust parameters from the scary internet, only allow the white list through.
    def cart_params
      params.fetch(:cart, {})
    end

    def invalid_cart
      logger.error "Attempt to access invalid cart #{params[:id]}"
      redirect_to root_path, notice: "That cart doesn't exist"
    end
end

Finally, the instruments controller associates Devise any new records that get created, edited, or destroyed here. We also authenticate on every restful path minus the index and show actions.

# app/controllers/instruments_controller.rb

class InstrumentsController < ApplicationController
  before_action :set_instrument, only: [:show, :edit, :update, :destroy]
  before_action :authenticate_user!, except: [:index, :show]

  # GET /instruments
  # GET /instruments.json
  def index
    @instruments = Instrument.all.order("created_at desc")
  end

  # GET /instruments/1
  # GET /instruments/1.json
  def show
  end

  # GET /instruments/new
  def new
    @instrument = current_user.instruments.build
  end

  # GET /instruments/1/edit
  def edit
  end

  # POST /instruments
  # POST /instruments.json
  def create
    @instrument = current_user.instruments.build(instrument_params)

    respond_to do |format|
      if @instrument.save
        format.html { redirect_to @instrument, notice: 'Instrument was successfully created.' }
        format.json { render :show, status: :created, location: @instrument }
      else
        format.html { render :new }
        format.json { render json: @instrument.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /instruments/1
  # PATCH/PUT /instruments/1.json
  def update
    respond_to do |format|
      if @instrument.update(instrument_params)
        format.html { redirect_to @instrument, notice: 'Instrument was successfully updated.' }
        format.json { render :show, status: :ok, location: @instrument }
      else
        format.html { render :edit }
        format.json { render json: @instrument.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /instruments/1
  # DELETE /instruments/1.json
  def destroy
    @instrument.destroy
    respond_to do |format|
      format.html { redirect_to instruments_url, notice: 'Instrument was successfully destroyed.' }
      format.json { head :no_content }
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_instrument
      @instrument = Instrument.find(params[:id])
    end

    # Never trust parameters from the scary internet, only allow the white list through.
    def instrument_params
      params.require(:instrument).permit(:brand, :model, :description, :condition, :finish, :title, :price, :image)
    end
end

Combining Items in a given cart

A tricky problem we run into is to somehow combine line items in a given cart. We can use a pretty hefty migration to create the new idea of doing just that.

$ rails g migration combine_items_in_cart
# db/migrate/combine_items_in_cart 
class CombineItemsInCart < ActiveRecord::Migration[5.1]
  def up
    Cart.all.each do |cart|
      sums = cart.line_items.group(:instrument_id).sum(:quantity)
      sums.each do |instrument_id, quantity|
        if quantity > 1
          cart.line_items.where(instrument_id: instrument_id).delete_all

          item = cart.line_items.build(instrument_id: instrument_id)
          item.quantity = quantity
          item.save!
        end
      end
    end
  end

  def down
    #split items with a quantity of 1 or more into multiple items
    LineItem.where("quantity>1").each do |line_item|
      line_item.quantity.times do
        LineItem.create(
          cart_id: line_item.cart_id,
          instrument_id: line_item.instrument_id,
          quantity: 1
        )
      end
      # remove original line item
      line_item.destroy
    end
  end
end

I get by with a little help from my friends (helpers!)

For this app, we defined a few helpers to determine the number of items in a users cart and display it in the nav bar from anywhere in the app.

# app/helpers/application_helper.rb

module ApplicationHelper

  def cart_count_over_one
    if @cart.line_items.count > 0
      return "<span class='tag is-dark'>#{@cart.line_items.count}</span>".html_safe
    end
  end

  def cart_has_items
    return @cart.line_items.count > 0
  end
end

Rather than typing out the rather long conditional I chose to abstract it into a help to see if a given instrument's author is indeed said, author.

# app/helpers/instruments_helper.rb

module InstrumentsHelper

  def instrument_author(instrument)
    user_signed_in? && current_user.id == instrument.user_id
  end

end

Seeding

Rails is pretty awesome in terms of setting yourself up for success. You can define some placeholder content from the get-go and seed that content into your app. There are gems out in the wild that help with generating some fake data for you to use where you please. Seeding data ultimately helps save time and time spent debugging your code. The last step of this series is a guide on how to seed data for this app.

If you made it this far I can't thank you enough for following along on my journey to get better with Ruby on Rails. I realize this app is incomplete but I do plan to introduce ideas revolving around accepting payments using rails as the framework behind the process. Future builds and or short videos will hopefully keep the learning coming. Until then be sure to check out my other videos and subscribe to my YouTube channel. You can also follow me on Twitter for instant updates of new content.

Shameless plug time

Hello Rails Course

I have a new course called Hello Rails. Hello Rails is modern course designed to help you start using and understanding Ruby on Rails fast. If you're a novice when it comes to Ruby or Ruby on Rails I invite you to check out the site. The course will be much like these builds but a super more in-depth version with more realistic goals and deliverables. View the course!

Follow @hello_rails and myself @justalever on Twitter.

Link this article
Est. reading time: 15 minutes
Stats: 9,232 views

Collection

Part of the Let's Build: With Ruby on Rails collection