- Published on
Implementing new features with Rails with EDD
- Authors
- Name
- Curtis Warcup
Error Driven Development (EDD) is a way to implement new features in a Rails application. We will be implementing a storewide sales feature in our Rails application.
Implementing Storewide Sales
- admins can create sales
- marks everything on sale by
x
% amount. - should be able to create sales date ranges
Example: Everything is 20% off from 2022-08-25 to 2022-08-31
Where do you start?
use an EDD approach
Ability for admins to create sale records
- name
- percent_off
- starts_on (date)
- ends_on (date)
once a sale is active, display it on the products#index page and affect the price of the product
Making a new route
In the config/routes.rb
file, we will add a new route:
namespace :admin do
root to: 'dashboard#index'
resources :products, except: %i[edit update show]
resources :categories, except: %i[edit update show]
#new
resources :sales, only: [:index]
end
use bin/rake routes
to see the new route.
Will see a new route:
admin_sales GET /admin/sales(.:format) admin/sales#index
- we have a route, but need a controller and view
- controller will be
admin/sales_controller.rb
- could use
bin/rails g controller admin/sales
to generate the controller
- controller will be
Creating the controller
bin/rails g controller <name_name_of_controller>
Use bin/rails g controller admin/sales
to generate the controller.
- creates the controller and the view
We now need an action! We have an empty controller.
Creating the action
- we need to create an action in the controller
- we will create an
index
action - this will be the action that will render the view
- we will create an
Recall, actions are methods on the class.
class Admin::SalesController < ApplicationController
def index
end
end
Next thing the action needs to do is... - possibly get some data from the database - needs to render a view
class Admin::SalesController < ApplicationController
def index
# render :index # this is implicit
end
end
But the view
index.html.erb
does not exist yet!
Creating the view
- we need to create the view
app/views/admin/sales/index.html.erb
<h1>Admin Sales</h1>
Should see this on the page if we visit
localhost:3000/admin/sales
Can use a view that is similar to the products#index
view.
<section class="admin-products-index">
<header class="page-header">
<h1>Admin » All Sales</h1>
</header>
<div class="well">
<%= link_to '+ New Sale', [:new, :admin, :sale], class: 'btn btn-info' %>
</div>
<div class="panel panel-default Sales">
<table class="table table-bordered">
<thead>
<tr>
<th colspan="2">Name</th>
<th>Start Date</th>
<th>End Date</th>
<th>Status</th>
<th>Percent Off</th>
<th></th>
</tr>
</thead>
<tbody>
<%= render @sales %> # need to create this instance in our controller
</tbody>
</table>
</div>
</section>
- will fail on the
render @sales
line- we need to create the
@sales
instance variable in the controller
- we need to create the
- also fail on the
link_to
line- we need to create the
new_admin_sale_path
route (does not exist yet) - essentially using
[:new, :admin, :sale]
to generate the pathnew_admin_sale_path
- we need to create the
To make the route new_admin_sale_path
work, we need to add a new route to the config/routes.rb
file:
namespace :admin do
root to: 'dashboard#index'
resources :products, except: %i[edit update show]
resources :categories, except: %i[edit update show]
resources :sales, only: [:index, :new] # add :new here
end
This creates the route new_admin_sale_path
and the controller action new
in the admin/sales_controller.rb
file.
admin_sales GET /admin/sales(.:format) admin/sales#index
new_admin_sale GET /admin/sales/new(.:format) admin/sales#new
now the button should render fine.
Soling the issue with <%= render @sales %>
- need to create the
@sales
instance variable in the controller- we will use the
Sale
model to get the data from the database- need to create this model first
- we will use the
Sale.all
method to get all the sales from the database
- we will use the
Creating the model and migration
bin/rails g model <name_of_model> <attribute_1>:<type> <attribute_2>:<type> ...
- we need to create the
Sale
modelbin/rails g model Sale name:string percent_off:integer starts_on:date ends_on:date
- this will create the migration file and the model file
- we will need to run the migration to create the table in the database
class Admin::SalesController < ApplicationController
def index
@sales = Sale.all
end
end
Running bin/rails g model Sale name:string percent_off:integer starts_on:date ends_on:date
will create the migration file and the model file.
class CreateSales < ActiveRecord::Migration[6.1]
def change
create_table :sales do |t|
t.string :name
t.integer :percent_off
t.date :starts_on
t.date :ends_on
t.timestamps
end
end
end
Running the migration
bin/rake db:migrate
- this will create the table in the database
- the controller should be able to run
Sale.all
now
At this point, we do not have any errors. We also need some data in the database for the view to render in the sales table.
Creating some data
You may assume you need a form in order to create a new sale. But we can use the rails console to create some data.
bin/rails c
- allows you to interact with your database
- use ActiveRecord to create some data
- see rails docs for more active record methods or here for v6.0
Sale.create!(name: "X-mas Sale!", percent_off: 20, starts_on: 'Dec 5, 2022', ends_on: 'Dec 25, 2022')
- uses ActiveRecord to
create
a new record in the database - is taking a hash of attributes
- takes in one argument (the entire hash)
_sale.html.erb
partial
Creating the - we need to create the
_sale.html.erb
partialapp/views/admin/sales/_sale.html.erb
<tr>
<td><%= sale.name %></td>
<td><%= sale.percent_off %></td>
<td><%= sale.starts_on %></td>
<td><%= sale.ends_on %></td>
<td><%= sale.status %></td>
<td><%= sale.percent_off %></td>
<td>
<%= link_to 'Edit', [:edit, :admin, sale], class: 'btn btn-info' %>
<%= link_to 'Delete', [:admin, sale], method: :delete, data: { confirm: 'Are you sure?' }, class: 'btn btn-danger' %>
</td>
</tr>
sales
is a local variable- we need to pass in the local variable to the partial
- we can do this in the
render
method
render @sales
How passing partials works <%= render @sales %>
- this is going through the array-like object
@sales
Is similar to:
<% @sales.each do |s| %>
<%= render 'sale', sale: s %>
<% end %>
rendering sale partial for each sale in the array-like object. This would technically work, but we can use the
render
method to do this for us.
- if you wanted to do something with the data before rendering the partial, you could do it in the controller
class Admin::SalesController < ApplicationController
def index
@sales = Sale.all
@sales.each do |sale|
sale.status = 'active'
end
end
end
index
view - dynamically rendering a partial
Updating the - we want to show the status of the sale as 'Active', 'Finished', or 'Upcoming'
- we need to add a method to the
Sale
model to do this - we can use the
status
method in the partial
- we need to add a method to the
<% if sale.ends_on < Date.current %>
<span> class="label label-danger">Finished</span>
<% end %>
but we have no way of testing this.
Back in out rails console, we can test this out (bin/rails c
)
- You can use a cool method called
30.days.ago
to get a date 30 days ago
Sale.create!(name: "March Sale!", percent_off: 20, starts_on: 30.days.ago, ends_on: 20.days.ago)
Update our view to show the status of the sale
<% if sale.ends_on < Date.current %>
<span> class="label label-danger">Finished</span>
<% elsif sale.starts_on > Date.current %>
<span> class="label label-warning">Upcoming</span>
<% end %>
Need a 3rd sale, that is active
Sale.create!(name: "Active Sale!", percent_off: 20, starts_on: 10.days.ago, ends_on: 10.days.from_now)
Update our view to show the status of the sale
<% if sale.ends_on < Date.current %>
<span> class="label label-danger">Finished</span>
<% elsif sale.starts_on > Date.current %>
<span> class="label label-warning">Upcoming</span>
<% else %>
<span> class="label label-success">Active</span>
<% end %>
The issue with this code is it is not very DRY. We are repeating ourselves. We can use a method in the model to do this for us.
Each of these if
statement in the view is determining business logic. A view should not be responsible for business logic. We should move this to the model.
We could do something like this:
```erb
<% if sale.finished? %>
<span> class="label label-danger">Finished</span>
<% elsif sale.starts_on > Date.current %>
<span> class="label label-warning">Upcoming</span>
<% else %>
<span> class="label label-success">Active</span>
<% end %>
When we add <% if sale.finished? %>
, the view is not doing any business logic, it is just asking the model if the sale is finished. The model is responsible for determining if the sale is finished.
Sale
model (finished?
)
Adding a method to the class Sale < ApplicationRecord
def finished?
ends_on < Date.current
end
def upcoming?
starts_on > Date.current
end
def active?
!finished? && !upcoming?
end
end
- we are not explicitly returning
true
orfalse
- Ruby will return the last line of code
- Ruby will return
true
if the last line of code is truthy - Ruby will return
false
if the last line of code is falsy
index
view - for the user/client side
Updating the - we have implemented some labels on the admin side to show if an item is on/off sale
- we need to implement the same labels on the user side
We need to be able to...
- display if an item is on sale
- display it on any page
- update the prices with the sale price
Within the views/layouts/application.html.erb
file, we can add a banner above our main yield
statement
Recall, this
yield
<main class="container">
# place code here that you want to appear in all your layouts
<%= yield %>
</main>
<main class="container">
<% if active_sale? %>
<p class="alert alert-info">
There's an active <%= "Back to school" %> sale going on!
Everything is <%= "20%" %> off!
</p>
<% end %>
<%= yield %>
</main>
When we run this we get an error saying undefined method 'active_sale?' for #<#<Class:0x00007f9b0b0b0e00>:0x00007f9b0b0b0d60>
This is because the active_sale?
method is not defined.
We can do this by creating a helper method.
Creating a helper method
- is found in the
app.helpers
folder - will be a module
Add a method but do not add logic in it yet:
module SalesHelper
def active_sale?
end
end
If we run the page we don't get an error. Let's add some logic to this method.
We want to see if there is an active sale:
module SalesHelper
def active_sale?
Sale.where("starts_on <= ? AND ends_on => ?", Date.current, Date.current).any?
end
end
here we are using an SQL query to find any sales that are active
Lets break down this query:
- we use
?
to represent a variable- these are passed in as arguments to the method
Basically does this:
Sale.where("starts_on <= Date.current AND ends_on => Date.current")
See more in the Rails Guide
We are still doing a lot of business logic here. We can move this to the model.
active_sale?
method to the Sale
model with class methods
Moving the - class methods can be added by using
self.
- add in the logic from the helper method
- here we are adding an ActiveRecord scope
class Sale < ApplicationRecord
# Active record scope method
def self.active
where("starts_on <= ? AND ends_on => ?", Date.current, Date.current)
end
def finished?
ends_on < Date.current
end
def upcoming?
starts_on > Date.current
end
def active?
!finished? && !upcoming?
end
end
Could also be done by using a lambda:
class Sale < ApplicationRecord
# Active record scope method
scope :active, -> { where("starts_on <= ? AND ends_on => ?", Date.current, Date.current) }
#...rest of the methods
end
Back in our helper we can now call the active
method:
module SalesHelper
def active_sale?
Sale.active.any?
end
end
Much cleaner!
General steps
- routing
config/routes.rb
- controller
bin/rails g controller <name_name_of_controller>
- action (method inside the controller)
def index; end
- view
app/views/<name_name_of_controller>/<name_of_action>.html.erb
- model (only if we need to access data from the database)
bin/rails g model <name_of_model>
Enter rails console
bin/rails c
Similar to irb