Nested resources
With the project resource CRUD done in chapter 4, the next step is to set up the
ability to create tickets within the scope of a given project. This chapter explores how to set
up a nested resource in Rails, by defining routes for Ticket resources and
creating a CRUD interface for them, all scoped under the Project resource that you just created. In
this chapter, you’ll see how easy it is to retrieve all ticket records for a specific project
and perform CRUD operations on them, mainly with the powerful associations interface that Rails
provides through its Active Record component.
Creating tickets
To add the functionality to create tickets under projects, you’ll apply Behaviour Driven Development
here again, writing RSpec specs with Capybara. Nesting
one resource under another involves additional routing, working with associations in Active
Record, and using more calls to before_action. Let’s get into this.
To create tickets for your application, you need an idea of what you’re going
to implement. You want to create tickets only for particular projects, so you need a "New
Ticket" link on a project’s show page. The link must lead to a form where a name
and a description for your ticket can be entered, and the form needs a button that submits it
to a create action in your controller. You also want to ensure that
the data entered is valid, as you did with the Project model. This
new form will look like:
Start by using the code from the following listing in a new file.
require "rails_helper"
RSpec.feature "Users can create new tickets" do
before do
project = FactoryBot.create(:project, name: "Internet Explorer")
visit project_path(project)
click_link "New Ticket"
end
scenario "with valid attributes" do
fill_in "Name", with: "Non-standards compliance"
fill_in "Description", with: "My pages are ugly!"
click_button "Create Ticket"
expect(page).to have_content "Ticket has been created."
end
scenario "when providing invalid attributes" do
click_button "Create Ticket"
expect(page).to have_content "Ticket has not been created."
expect(page).to have_content "Name can't be blank"
expect(page).to have_content "Description can't be blank"
end
end
You’ve seen the before method before, in the last chapter when we were setting up the
project data we needed for our "Editing Projects" spec to run. Here we’re doing
a similar thing - setting up the project that our tickets will be attached to.
Your ticket objects need a parent project object to belong to (in our system a
ticket can’t exist outside of a project), so it makes sense to build one before
every test.
In the above spec, we want to make sure to test the basic functionality of creating a ticket. It’s pretty straightforward: start on the project page, click the "New Ticket" link, fill in the attributes, click the button, and make sure it works!
You should also test the failure case. Because you need to have a name and description, a failing case is easy: we click the "Create Ticket" button prematurely, before filling out all of the required information.
Now run this new feature using the bundle exec rspec
spec/features/creating_tickets_spec.rb command, both of your tests will fail
due to your before block:
1) Users can create new tickets with valid attributes
Failure/Error: click_link "New Ticket"
Capybara::ElementNotFound:
Unable to find link "New Ticket"
# and the second error is identical
You need to add this "New Ticket" link to the bottom of the app/views/projects/show.html.erb
template, so that this line in the test will work. We’ll copy the format we did
for the projects index view, and build a header with the action link in it.
<header>
<h2>Tickets</h2>
<%= link_to "New Ticket", new_project_ticket_path(@project) %>
</header>
This helper is called a nested routing helper, and it’s like the standard routing helper. The similarities and differences between the two are explained in the next section.
Nested routing helpers
When defining the "New Ticket" link, you used a nested routing
helper—new_project_ticket_path—rather than a standard routing helper such as
new_ticket_path, because you want to create a new ticket for a given
project. Both helpers work in a similar fashion, except the nested routing
helper always takes at least one argument: the Project object that the ticket
belongs to. This is the object you’re nested inside. The route to any
ticket URL is always scoped by /projects/:id in your application. This helper
and its brethren are defined by changing this line in config/routes.rb:
resources :projects
to these lines:
resources :projects do
resources :tickets
end
This code tells the routing for Rails that you have a tickets resource
nested inside the projects resource. Effectively, any time you access a
ticket resource, you access it within the scope of a project. Just as the
resources :projects method gave you helpers to use in controllers and views,
this nested one gives you the helpers shown in this table:
| Route | Helper |
|---|---|
/projects/:project_id/tickets |
|
/projects/:project_id/tickets/new |
|
/projects/:project_id/tickets/:id/edit |
|
/projects/:project_id/tickets/:id |
|
The routes belonging to a specific Ticket instance will now take two parameters -
the project that the ticket belongs to, and the ticket itself - to generate URLs
like http://localhost:3000/projects/1/tickets/2/edit.
As before, you can use the *_url or *_path alternatives to these helpers,
such as project_tickets_url, to get the full URL if you so desire.
In the table’s left column are the routes that can be accessed, and in the
right are the routing helper methods you can use to access them. Let’s use them by first
creating your TicketsController.
Creating a tickets controller
Because you defined this route in your routes.rb file, Capybara can now click
the link in your feature and proceed before complaining about the missing
TicketsController. If you re-run your spec with bundle exec rspec spec/features/creating_tickets_spec.rb,
it spits out an error followed by a stack trace:
1) Users can create new tickets with valid attributes
Failure/Error: click_link "New Ticket"
ActionController::RoutingError:
uninitialized constant TicketsController
# and the second error is identical
Some guides may have you generate the model before you generate the
controller, but the order in which you create them isn’t important. When
writing tests, you follow the bouncing ball; and if the test tells you it
can’t find a controller, then the next thing you do is generate the controller
it’s looking for. Later, when you inevitably receive an error that it can’t
find the Ticket model, as you did for the Project model, you generate
that, too. This is often referred to as top-down
design [1].
To generate this controller and fix the uninitialized
constant error, use this command:
$ rails g controller tickets
You may be able to pre-empt what’s going to happen next if you run the test:
it’ll complain of a missing new action that it’s trying to get to by
clicking the "New Ticket" link. Let’s just re-run the test to make sure:
1) Users can create new tickets with valid attributes
Failure/Error: click_link "New Ticket"
AbstractController::ActionNotFound:
The action 'new' could not be found for TicketsController
So our next step is to define the new action. Open
app/controllers/tickets_controller.rb, and add the new action inside the
TicketsController definition, as shown in the
following listing.
new action from TicketsController
def new
@ticket = @project.tickets.build
end
There’s a lot of magic in this one line. We’re referring to @project but we
haven’t defined it, we’re referring to a tickets method on a Project instance
but we haven’t defined one, and we’re calling a method called build on whatever
tickets returns. Whew! One step at a time.
Demystifying the new action
We’ll start with the @project instance variable. As we declared in our routes,
our tickets resource is nested under a projects resource, giving us URLs
like those shown in the earlier table, which is reproduced below:
| Route | Helper |
|---|---|
/projects/:project_id/tickets |
|
/projects/:project_id/tickets/new |
|
/projects/:project_id/tickets/:id/edit |
|
/projects/:project_id/tickets/:id |
|
The placeholders in the URLs (:project_id and :id) are what we get as part
of our params when we request these URLs. When we request http://localhost:3000/projects/1/tickets/2,
our placeholders have the values of 1 and 2, so params will include the following:
{ project_id: 1, id: 2 }
We can use the provided :project_id value to load up the right Project instance
in a before_action, like we did for certain actions in our ProjectsController.
Unlike the ProjectsController though, this before_action will be run before
every action, because the project will always be present; and it will use
params[:project_id], instead of params[:id]
Add the following line under the class definition in app/controllers/tickets_controller.rb:
before_action :set_project
And now under the new action, you can define this set_project method that
will use the params[:project_id] variable to load the @project variable.
private
def set_project
@project = Project.find(params[:project_id])
end
Now our @project variable is defined. What about a tickets method? Is that
the next thing we need to define? Re-run the test with bundle exec rspec
spec/features/creating_tickets_spec.rb to see:
1) Users can create new tickets with valid attributes
Failure/Error: click_link "New Ticket"
NoMethodError:
undefined method `tickets' for #<Project:0x007f26fa162628>
It is the next thing we need to define. We’ll define tickets to be an
association on our Project model - a link between the two models, so we can
call @project.tickets and get an array of all of the Ticket instances that
are part of the @project. Seems magical. Let’s look at how it works.
Defining a has_many association
The tickets method on Project objects is defined by calling an association method
in the Project class called has_many, add this as follows in app/models/project.rb:
class Project < ActiveRecord::Base
has_many :tickets
...
As mentioned before, this defines the tickets method you need as well, as the
association. With the has_many method called in the Project model, you can
now get to all the tickets for any given project by calling the tickets
method on any Project object.
By defining a has_many association in the model, it also gives you a whole
slew of other useful methods[2],
such as the build method, which you’re also
currently calling in the new action of TicketsController. The build
method is equivalent to new for the Ticket class (which you create in a
moment) but associates the new object instantly with the @project object by
setting a foreign key called project_id automatically.
Upon rerunning bundle exec rspec
spec/features/creating_tickets_spec.rb, you’ll get this:
1) Users can create new tickets with valid attributes
Failure/Error: click_link "New Ticket"
NameError:
uninitialized constant Project::Ticket
You can determine from this output that the method is looking for the Ticket
class, but why? The tickets method on Project objects is defined by the
has_many call in the Project model. This method assumes that when you want
to get the tickets, you actually want instances of the Ticket model. This
model is currently missing; hence, the error. You can add this model now with
the following command:
$ rails g model ticket name description:text project:references
The project:references part defines an integer column for the tickets
table called project_id.
It also defines an index on this column so that lookups for the tickets for a specific project will be faster. The new migration for this model looks like this:
class CreateTickets < ActiveRecord::Migration[7.0]
def change
create_table :tickets do |t|
t.string :name
t.text :description
t.references :project, null: false, foreign_key: true
t.timestamps
end
end
end
The project_id column represents the project to which this ticket links and
is called a foreign key. The purpose of this field is to
store the primary key of the project the ticket relates to. By creating a
ticket on the project with the id field of 1, the project_id field in the
tickets table will also be set to 1.
The foreign_key: true part of the command enforces database-level foreign key restrictions
for those platforms that support it, such as PostgreSQL. You can read more about the specifics of
Rails foreign key support at:
http://guides.rubyonrails.org/4_2_release_notes.html#foreign-key-support. The
SQLite driver we’re using doesn’t support foreign keys like this, so we don’t get
any benefit from specifying them, but nor does it do any harm. We’ll also be
looking at using PostgreSQL when we cover a different data during the Deployment chapter, Chapter 13.
Run the migration with rails db:migrate.
The db:migrate task runs the migrations and then dumps the
structure of the database to a file called db/schema.rb. This structure
allows you to restore your database using the rails db:schema:load
task
if you wish, which is better than running all the migrations on a large
project again![3]
Now when you run bundle exec rspec spec/features/creating_tickets_spec.rb,
you’re told the new template is missing:
1) Users can create new tickets with valid attributes Failure/Error: click_link "New Ticket" TicketsController#new is missing a template for request formats: text/html
You must create this file in order to continue.
Creating tickets in a project
Create the file at app/views/tickets/new.html.erb, and put the following
in it:
<header>
<h1>
<%= link_to @project.name, @project %>
</h1>
<h2>
New Ticket
</h2>
</header>
<%= render "form", project: @project, ticket: @ticket %>
Like we did with projects, this template will render a form partial (so we can
reuse it for the edit page when we get to it). The partial also goes in the
app/views/tickets folder. Create a new file called _form.html.erb, using this code:
<%= form_with(model: [project, ticket]) do |form| %>
<% if ticket.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(ticket.errors.count, "error") %>
prohibited this project from being saved:</h2>
<ul>
<% ticket.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<div>
<%= form.label :name %>
<%= form.text_field :name %>
</div>
<div>
<%= form.label :description %>
<%= form.text_area :description %>
</div>
<%= form.submit %>
<% end %>
Note that form_with is passed an array of objects rather than:
<%= form_with(model: @ticket) do |f| %>
This code indicates to form_with that you want the form to post to a nested
route. For the new action, this generates a route like
/projects/1/tickets and for the edit action, it generates a route like
/projects/1/tickets/2. This type of routing is known as polymorphic
routing.[4]
When you run bundle exec rspec spec/features/creating_tickets_spec.rb again,
you’re told the create action is missing:
1) Users can create new tickets with valid attributes
Failure/Error: click_button "Create Ticket"
AbstractController::ActionNotFound:
The action 'create' could not be found for TicketsController
To define this action, put it directly under the new action in TicketsController but before the
private method. Also add the appropriate strong parameters
helper method right below private, as shown in the
following listing.
create action from TicketsController
def create
@ticket = @project.tickets.build(ticket_params)
if @ticket.save
flash[:notice] = "Ticket has been created."
redirect_to [@project, @ticket]
else
flash.now[:alert] = "Ticket has not been created."
render :new, status: :unprocessable_entity
end
end
private
def ticket_params
params.require(:ticket).permit(:name, :description)
end
In this action, you use redirect_to and specify an Array—the same array
you used in form_with earlier—containing a Project object and a Ticket
object. Rails inspects any array passed to helpers, such as redirect_to and
link_to, and determines what you mean from the values. For this particular
case, Rails figures out that you want this helper:
project_ticket_path(@project, @ticket)
Rails determines this helper because, at this stage, @project and @ticket
are both objects that exist in the database, and you can therefore route to
them. The route generated would be /projects/1/tickets/2 or something similar.
Back in the form_with, @ticket was new, so the route happened to be
/projects/1/tickets. You could have been explicit and specifically used
project_ticket_path in the action, but using an array is less repetitive.
When you run bundle exec rspec spec/features/creating_tickets_spec.rb, both
scenarios continue report the same error:
1) Users can create new tickets with valid attributes
Failure/Error: click_button "Create Ticket"
AbstractController::ActionNotFound:
The action 'show' could not be found for TicketsController
Therefore, you must create a show action for
the TicketsController. But when you do so, you’ll need to find
tickets only for the given project.
Finding tickets scoped by project
Currently, both of your scenarios are failing due to a missing action. The next
logical step is to define the show action for your controller, which will look
up a given ticket by ID. But, being quick to learn and spot trends, you can
anticipate that you’ll also need to find a ticket by ID for the edit, update,
and destroy actions, and pre-empt similar errors when it comes to building
those actions. You can make this a before_action, as you did in the
ProjectsController with the set_project method. You define this finder
under the set_project method in the TicketsController:
def set_ticket
@ticket = @project.tickets.find(params[:id])
end
find is yet another association
method provided by Rails when you declared that your Project model has_many
:tickets. This code attempts to find tickets only within the collection of
tickets owned by the specified project. Put your new before_action at the
top of your class, under the action to find the project:
before_action :set_project
before_action :set_ticket, only: %i(show edit update destroy)
The sequence here is important because you want to find the @project before
you go looking for tickets for it. Then you can create the action that your
test is asking for, below the create method (but above private) in your
TicketsController:
show action in app/controllers/tickets_controller.rbdef show
end
Again, it doesn’t need to have anything in it - we’ve already loaded all of the
content the action needs, in our before_action calls. But it’s good to know it’s
there. Then create the view template for this
action at app/views/tickets/show.html.erb, using this code:
<header>
<h1><%= link_to @project.name, @project %></h1>
</header>
<header>
<h2><%= @ticket.name %></h2>
</header>
<%= simple_format(@ticket.description) %>
The new method, simple_format, converts the line
breaks[5] entered into the
description field into HTML break tags (<br>) so that the description
renders exactly how the user intends it to.
Based solely on the changes you’ve made so far, your first scenario should be
passing. Let’s see with a quick run of bundle exec rspec
spec/features/creating_tickets_spec.rb:
1) Users can create new tickets when providing invalid attributes
Failure/Error: expect(page).to have_content "Ticket has not been
created."
expected to find text "Ticket has not been created." in "Ticket has been created.\nInternet Explorer"
...
2 examples, 1 failure
This means you’ve got the first scenario under control, and users of your
application can create tickets within a project. Next, you need to add
validations to the Ticket model to get the second scenario to pass.
Ticket validations
The second scenario fails because the @ticket
that it saves isn’t valid, at least according to your tests in their current state:
expected to find text "Ticket has not been created." in "Ticketee ...
You need to ensure that when somebody enters a ticket into the application,
the name and description attributes are filled in. To do this, define the
following validations in the Ticket model.
validates :name, presence: true
validates :description, presence: true
|
Validating two fields using one line
You could also validate the presence of both of these fields using a single line:
However, it is easier to see the associations for a given field if they are all in one place. If you were to add, for example, an extra length validation to the description field, it might look like this:
And it would not be immediately obvious that both validations apply to one
field (the So it’s our preference to have validations for different fields on individual lines. You don’t have to use two lines to do it; we can still be friends. |
Now, when you run bundle exec spec/features/creating_tickets_spec.rb, the
entire feature passes:
2 examples, 0 failures
Before we wrap up here, let’s add one more scenario to ensure that what is
entered into the "Description" field is longer than 10 characters. You want the
descriptions to be useful! Add this scenario to the
spec/features/creating_tickets_spec.rb file:
scenario "with an invalid description" do
fill_in "Name", with: "Non-standards compliance"
fill_in "Description", with: "It sucks"
click_button "Create Ticket"
expect(page).to have_content "Ticket has not been created."
expect(page).to have_content "Description is too short"
end
To implement the code needed to make this scenario pass, add another
option to the end of the validation for the description in your
Ticket model, like this:
validates :description, presence: true, length: { minimum: 10 }
By default, this will generate a message identical to the one we’ve used
in our test. You can verify this with the console - if you run
rails console and try to create a new Ticket object by using create!,
you can get the full text for your error:
irb(main):001:0> Ticket.create! ActiveRecord::RecordInvalid: ... Description is too short (minimum is 10 characters)
If you’re getting that error message on the console, that means it will appear
like that in the app too. Find out by running bundle exec rspec
spec/features/creating_tickets_spec.rb again:
3 examples, 0 failures
That one’s passing now. Excellent! You should ensure that the rest of the
project still works by running bundle exec rspec again. You’ll see this output:
13 examples, 0 failures, 3 pending
There are three pending specs here: one located in
spec/helpers/tickets_helper_spec.rb, one in spec/requests/tickets_spec.rb and the other in
spec/models/ticket_spec.rb. These were automatically generated when you ran
the commands to generate your TicketsController and Ticket model - you
don’t need them right now, so you can just delete these three files. When you’ve
done that, rerunning bundle exec rspec outputs a lovely green result:
10 examples, 0 failures
Great! Everything’s still working. Commit and push the changes!
$ git add . $ git commit -m "Implement creating tickets for a project" $ git push
This section covered how to create tickets and link them to a specific
project through the foreign key called project_id on records in
the tickets table. The next section shows how easily you can list tickets for individual
projects.
Viewing tickets
Now that you have the ability to create tickets, you’ll use the show action
of the TicketsController to view them individually. When displaying a list
of projects, you use the index action of the ProjectsController. For
tickets, however, we’ll list them them as part of showing the details of a
project, on the show action of the ProjectsController. This page currently isn’t
being used for anything else in particular, but also it just makes sense to see
the project’s tickets when you view the project. To test it, put a new feature at
spec/features/viewing_tickets_spec.rb using the code from the following
listing.
require "rails_helper"
RSpec.feature "Users can view tickets" do
before do
vscode = FactoryBot.create(:project, name: "Visual Studio Code")
FactoryBot.create(:ticket, project: vscode,
name: "Make it shiny!",
description: "Gradients! Starbursts! Oh my!")
ie = FactoryBot.create(:project, name: "Internet Explorer")
FactoryBot.create(:ticket, project: ie,
name: "Standards compliance", description: "Isn't a joke.")
visit "/"
end
scenario "for a given project" do
click_link "Visual Studio Code"
expect(page).to have_content "Make it shiny!"
expect(page).to_not have_content "Standards compliance"
click_link "Make it shiny!"
within(".ticket h2") do
expect(page).to have_content "Make it shiny!"
end
expect(page).to have_content "Gradients! Starbursts! Oh my!"
end
end
Quite the long feature! It covers a couple of things - both viewing the list
of tickets for a project, and then viewing the details for a specific
ticket.[6]
We’ll go through it piece by piece in a moment. First,
let’s examine the within usage in the scenario. Rather than checking the
entire page for content, this step checks the specific element using Cascading
Style Sheets (CSS) selectors. The .ticket h2 selector finds all h2 elements
within a div with the class attribute set to ticket, and then we make sure the content is
visible within one of those elements.[7]
This content should appear in the specified tag only when you’re on the ticket
page, so this is a great way to make sure you’re on the right page and that
the page is displaying relevant information.
When you run this spec with bundle exec rspec spec/features/viewing_tickets_spec.rb,
you’ll see that it can’t find the ticket factory:
Failure/Error:
FactoryBot.create(:ticket, project: vscode,
name: "Make it shiny!",
description: "Gradients! Starbursts! Oh my!")
KeyError:
Factory not registered: "ticket"
Just as before, when the project factory wasn’t registered, you need to create
the ticket factory now. It should create an example ticket with a valid name
and description. To do this, create a new file called
spec/factories/ticket.rb with the following content.
FactoryBot.define do
factory :ticket do
name { "Example ticket" }
description { "An example ticket, nothing more" }
end
end
With the ticket factory defined, the before block of this spec should now
run all the way through when you run bundle exec rspec
spec/features/viewing_tickets_spec.rb. You’ll see this error:
1) Users can view tickets for a given project
Failure/Error: expect(page).to have_content "Make it shiny!"
expected to find text "Make it shiny!" in "Visual Studio Code\nEdit Project Delete Project\nTickets\nNew Ticket"
The spec is attempting to see the ticket’s name on the page. But it can’t see
it at the moment, because you’re not displaying a list of tickets on the
project show template yet.
Listing tickets
To display a ticket on the show template, you can iterate through the
project’s tickets by using the tickets method on a Project object, made
available by the has_many :tickets call in your model. Put this code at the
bottom of app/views/projects/show.html.erb.
<ul>
<% @project.tickets.each do |ticket| %>
<li>
#<%= ticket.id %> -
<%= link_to ticket.name, [@project, ticket] %>
</li>
<% end %>
</ul>
|
Be careful when using
link_to.If you use a |
Here you iterate over the items in @project.tickets using the each method,
which does the iterating for you, assigning each item to a ticket variable
used in the block. The code in this block runs for every ticket. When you run
bundle exec rspec spec/features/viewing_tickets_spec.rb, it passes because the app
now has the means to go to a specific ticket from the project’s page:
1 example, 0 failures
Time to make sure everything else is still working by running
bundle exec rspec. You should see all green:
11 examples, 0 failures
Fantastic! Push!
$ git add . $ git commit -m "Implement tickets display" $ git push
You can see tickets for a particular project, but what happens when a project
is deleted? The tickets for that project aren’t magically deleted. To
implement this behavior, you can pass some options to the has_many
association, which will delete the tickets when a project is deleted.
Culling tickets
When a project is deleted, its tickets become useless: they’re inaccessible
because of how you defined their routes. Therefore, when you delete a project,
you should also delete the tickets for that project. You can do that by using
the :dependent option on the has_many association for tickets defined in
your Project model.
This option has five choices that all act slightly differently. The first
one is the :destroy value:
has_many :tickets, dependent: :destroy
If you put this in your Project model, any time you call destroy on a
Project object, Rails will iterate through the tickets for this project,
and call destroy on each of them in turn (as well as any other destroy-related
callbacks on the project itself). In turn, each ticket object will have any
destroy-related callbacks called on it, and if it has any has_many
associations with the dependent: :destroy option set, then those objects
will be destroyed, and so on. The problem is that if you have a
large number of tickets, destroy is called on each one, which will be slow.
The solution is the second value for this option:
has_many :tickets, dependent: :delete_all
This deletes all the tickets using a SQL delete, like this:
DELETE FROM tickets WHERE project_id = :project_id
This operation is quick and is exceptionally useful if you have a large number
of tickets that don’t have callbacks or that have callbacks you don’t
necessarily care about when deleting a project. If you do have callbacks on
Ticket for a destroy operation, then you should use the first option,
dependent: :destroy.
Thirdly, if you want to disassociate tickets from a project and unset the
project_id field, you can use this option:
has_many :tickets, dependent: :nullify
When a project is deleted with this type of :dependent option defined, it will execute an SQL query such as this:
UPDATE tickets SET project_id = NULL WHERE project_id = :project_id
Rather than deleting the tickets, this option keeps them around; but their
project_id fields are unset, leaving them orphaned, which isn’t suitable for
this system.
Using this option would be helpful, for example, if you were building a task-
tracking application and instead of projects and tickets, you had users and
tasks. If you deleted a user, you might want to unassign rather than delete
the tasks associated with that user, in which case you’d use the dependent:
:nullify option instead.
Finally, you have two options that work similarly - :restrict_with_error and
:restrict_with_exception. These options will both prevent
records from being deleted if the association isn’t empty - for example, in
our projects and tickets scenario we wouldn’t be able to delete projects if
they had any tickets in them.
If we were using :restrict_with_error then calling @project.destroy on a
project with tickets would add a validation error to the @project instance,
as well as returning false. Using :restrict_with_exception in this case
would raise an exception that our application would have to manually catch
and handle, or else the user would receive a HTTP response of 500 - Internal
Server Error. An example of where this could be useful is in a billing scenario: it wouldn’t
be good for business if users were able to cancel/delete their own accounts in
your system, if they had associated bills that still required payment.
In our projects and tickets scenario, though, you would use dependent: :destroy
if you have callbacks to run on tickets when they’re destroyed or dependent:
:delete_all if you have no callbacks on tickets. To ensure that all tickets
are deleted on a project when the project is deleted, change the has_many
association in your Project model to this:
has_many :tickets, dependent: :delete_all
With this new :dependent option in the Project model, all tickets for the
project will be deleted when the project is deleted.
You aren’t writing any tests for this behavior, because it’s simple and you’d
basically be testing that you changed one tiny option. This is more of an
internal implementation detail than it is customer-facing, and you’re writing
feature tests right now, not model tests. Let’s check that you didn’t
break existing tests by running bundle exec rspec:
10 examples, 0 failures
Good! Let’s commit:
$ git add . $ git commit -m "Cull tickets when project gets destroyed" $ git push
Next, let’s look at how to edit the tickets in your application.
Editing tickets
You want users to be able to edit tickets, the updating part of this CRUD
interface for tickets. This section covers creating the edit and update
actions for the TicketsController. This functionality follows a thread
similar to the projects edit feature, where you follow an "Edit" link in the
show template, change a field, and then click an update button and expect to
see two things: a message indicating that the ticket was updated successfully,
and the modified data for that ticket.
As always, we’ll start with the test that covers the functionality we wish we had. Then we’ll write the code that will make the test pass.
The "Editing tickets" spec
Just as you made a spec for creating a ticket, you need one for editing and updating existing tickets. Specs testing update functionality are always a little more complex than specs for testing create functionality, because you need to have an existing object that’s built properly before the test, and then you can change it during the test.
With that in mind, you can write this feature using the code in the
following listing. Put the code in a file at
spec/features/editing_tickets_spec.rb.
require "rails_helper"
RSpec.feature "Users can edit existing tickets" do
let(:project) { FactoryBot.create(:project) }
let(:ticket) { FactoryBot.create(:ticket, project: project) }
before do
visit project_ticket_path(project, ticket)
end
scenario "with valid attributes" do
click_link "Edit Ticket"
fill_in "Name", with: "Make it really shiny!"
click_button "Update Ticket"
expect(page).to have_content "Ticket has been updated."
within(".ticket h2") do
expect(page).to have_content "Make it really shiny!"
expect(page).not_to have_content ticket.name
end
end
scenario "with invalid attributes" do
click_link "Edit Ticket"
fill_in "Name", with: ""
click_button "Update Ticket"
expect(page).to have_content "Ticket has not been updated."
end
end
At the top of this feature, you use a new RSpec method called let. In fact,
you use it twice. It defines a new method with the same name as the symbol passed
in, and that new method then evaluates (and caches) the content of the block whenever that
method is called. It’s also lazy-loaded - the block won’t get evaluated until
the first time you call the method denoted by the symbol, eg. project or ticket in this case.
It also has a bigger brother, called let! (with a bang!) let! isn’t lazy-loaded - when
you define a method with let! it will be evaluated immediately, before your tests
start running.
For a concrete example, if we had a test that looked like the following:
let and let!
RSpec.describe "A sample test" do
let!(:project) { FactoryBot.create(:project) }
let(:ticket) { FactoryBot.create(:ticket) }
it "lazily loads `let` methods" do
puts Project.count
puts Ticket.count
puts ticket.name
puts Ticket.count
end
end
If you were to run it, what do you think it might output? If you guessed the following:
-
Project.count→ 1 (asprojectis already evaluated) -
Ticket.count→ 0 (tickethas not been evaluated yet) -
ticket.name→ "Example ticket" (from our factory) -
Ticket.count→ 1 (tickethas now been evaluated and exists in the database)
You’re right!
In our case, it makes no difference if we use let or let!. The first
thing we’re doing in the before block is instantiating both project and
ticket by visiting the ticket’s show page. If, however, we were visiting the
homepage and then navigating to the ticket’s page, it wouldn’t work - the ticket
would never be created.
After we visit the ticket’s show page, then we click the "Edit" link, make
some changes, and verify that those changes get persisted. We’re also testing
the failure case - what happens if we can’t update a ticket
for some reason. It looks pretty similar to the update case, but rather than try
to factor out all the commonalities, you
repeat yourself. Some duplication in tests is OK; if it makes the test easier
to follow, it’s worth a little repetition.
When you run this feature using bundle exec rspec
spec/features/editing_tickets_spec.rb, the first three lines in the before
run fine, but the fourth fails:
1) Users can edit existing tickets with valid attributes
Failure/Error: click_link "Edit Ticket"
Capybara::ElementNotFound:
Unable to find link "Edit Ticket"
To fix this, add the "Edit Ticket" link to the show template of the
TicketsController, because that’s the page you’ve visited in the feature.
It sounds like an action link for the ticket, so we can add a list of action
links into the header that specifies the ticket’s name.
<header>
<h2><%= @ticket.name %></h2>
<li><%= link_to "Edit Ticket", [:edit, @project, @ticket] %></li>
</header>
Here is yet another use of the Array argument passed to the link_to
method, but rather than passing just Active Record objects, you pass a Symbol
first. Rails, yet again, works out from the Array what route you wish to
follow. Rails interprets this array to mean the edit_project_ticket_path
method, which is called like this:
edit_project_ticket_path(@project, @ticket)
Now that you have an "Edit Ticket" link, you need to add the edit action to
the TicketsController, because that will be the next thing to error when you
run bundle exec rspec spec/features/editing_tickets_spec.rb:
1) Users can edit existing tickets with valid attributes
Failure/Error: click_link "Edit Ticket"
AbstractController::ActionNotFound:
The action 'edit' could not be found for TicketsController
...
2 examples, 2 failures
Adding the edit action
The next logical step is to define the edit action in your
TicketsController. Like the edit action in ProjectsController, it doesn’t
technically need to exist because it will be empty - all it needs to do is load
the @project and @ticket variables, which are already done via set_project
and set_ticket. But it’s good practice to define it, so add it in under the
show action in TicketsController, but before the private call.
def edit
end
The next logical step is to create the view for this action. Put it at
app/views/tickets/edit.html.erb, and fill it with this content:
<header>
<h1>
Edit Ticket
<small><%= @project.name %></small>
</h1>
</header>
<%= render "form", project: @project, ticket: @ticket %>
Here you reuse the form partial you created for the new action, which is
handy. The form_with knows which action to go to. If you run the feature spec
again with bundle exec rspec spec/features/editing_tickets_spec.rb, you’re told the
update action is missing:
1) Users can edit existing tickets with valid attributes
Failure/Error: click_button "Update Ticket"
AbstractController::ActionNotFound:
The action 'update' could not be found for TicketsController
Adding the update action
You should now define the update action in your TicketsController, as shown in the following listing.
update action of TicketsController
def update
if @ticket.update(ticket_params)
flash[:notice] = "Ticket has been updated."
redirect_to [@project, @ticket]
else
flash.now[:alert] = "Ticket has not been updated."
render :edit,
end
end
Remember that in this action you don’t have to find the @ticket or
@project objects, because a before_action does it for the show, edit,
update, and destroy actions. With this single action implemented, both
scenarios in the "Editing Tickets" feature will now pass when you run bundle exec
rspec spec/features/editing_tickets_spec.rb:
2 examples, 0 failures
Check to see if everything works with a quick run of bundle exec rspec:
13 examples, 0 failures
Great! Let’s commit and push that:
$ git add . $ git commit -m "Tickets can now be edited" $ git push
In this section, you implemented edit and update for the
TicketsController by using the scoped finders and some familiar methods,
such as update. You’ve got one more part to go: deletion.
Deleting tickets
We now reach the final story for this nested resource, deleting tickets. As
with some of the other actions in this chapter, this story doesn’t differ from
what you used in the ProjectsController, except you’ll change the name
project to ticket for your variables and flash[:notice]. It’s good to
have the reinforcement of the techniques previously used: practice makes
perfect.
Use the code from the next listing to write a new feature in
spec/features/deleting_tickets_spec.rb.
require "rails_helper"
RSpec.feature "Users can delete tickets" do
let(:project) { FactoryBot.create(:project) }
let(:ticket) { FactoryBot.create(:ticket, project: project) }
before do
visit project_ticket_path(project, ticket)
end
scenario "successfully" do
click_button "Delete Ticket"
expect(page).to have_content "Ticket has been deleted."
expect(page.current_url).to eq project_url(project)
expect(page).not_to have_content(ticket.name)
end
end
When you run this spec using bundle exec rspec
spec/features/deleting_tickets_spec.rb, it will fail because you don’t yet have a
"Delete Ticket" link on the show template for tickets:
1) Users can delete tickets successfully
Failure/Error: click_button "Delete Ticket"
Capybara::ElementNotFound:
Unable to find button "Delete Ticket"
You can add the "Delete Ticket" button to the list of actions on
app/views/tickets/show.html.erb, right after the "Edit Ticket" link.
<%= button_to "Delete Ticket", [@project, @ticket], method: :delete,
form: {
data: { turbo_confirm: "Are you sure you want to delete this ticket?"}
}
%>
The method: :delete is specified again, turning the request into one headed
for the destroy action in the controller. Upon running bundle exec rspec spec/features/deleting_tickets_spec.rb
again, you’re told a destroy action is missing:
1) Users can delete tickets successfully
Failure/Error: click_button "Delete Ticket"
AbstractController::ActionNotFound:
The action 'destroy' could not be found for TicketsController
The next step must be to define this action, right? Open
app/controllers/tickets_controller.rb, and define it directly under the
update action.
destroy action from TicketsController
def destroy
@ticket.destroy
flash[:notice] = "Ticket has been deleted."
redirect_to @project
end
After you delete the ticket, you redirect the user back to the show page for
the project the ticket belonged to. With that done, your feature should now pass
when you run bundle exec rspec spec/features/deleting_tickets_spec.rb again:
1 example, 0 failures
Yet again, check to see that everything is still going as well as it should by
using bundle exec rspec. You haven’t changed much, so it’s likely that things are
still working. You should see this output:
14 examples, 0 failures
Commit and push!
$ git add . $ git commit -m "Implement deleting tickets feature" $ git push
git
You’ve now completely created another CRUD interface, this time for the
tickets resource, which is only accessible within the scope of a project.
This means you must request it using a URL such as /projects/1/tickets/2
rather than /tickets/2.
Summary
In this chapter, you generated another controller, the TicketsController,
which allows you to create records for your Ticket model that will end up in
your tickets table. The difference between this controller and the
ProjectsController is that the TicketsController is accessible only within
the scope of an existing project, because you used nested routing.
In this controller, you scoped the finds for the Ticket model by using the
tickets association method, provided by the association helper method
has_many call in your Project model. has_many also provides the build
method, which you used to begin creating new Ticket records that are scoped
to a project.
In the next chapter, you’ll learn how to let users sign up and sign in to your application. You’ll also implement a basic authorization for actions such as creating a project.