Lesson Weekend

In the last few lessons, we've covered adding full CRUD functionality for albums to our record store. In this lesson, we'll do the same for adding CRUD functionality for songs. This time, we'll go back and forth between controller actions and views. Much of the code will be the same but there will be a few new wrinkles. These key differences will be highlighted in bold.

Showing A List of Songs


Because our application has a one-to-many relationship between albums and songs, it doesn't make much sense to have a separate index action for showing every song. Instead, we'll just show the songs that belong to a specific album. This means that we can add this code to the show view of an album. Let's update that file now:

views/albums/show.html.erb
<h1><%= @album.name %></h1>
<h3><%= @album.genre %></h3>

<% if @album.songs.any? %>
  <h4>Here are the songs for this album:</h4>

  <% @album.songs.each do |song| %>
    <ul>
      <li>
        <%= link_to song.name, album_song_path(@album, song) %>
      </li>
    </ul>
  <% end %>
<% else %>
  <p>No songs are listed for this album.</p>
<% end %>

<p><%= link_to "Add a song", new_album_song_path(@album) %></p>

<p><%= link_to "Edit", edit_album_path(@album) %></p>
<p><%= link_to "Delete album", album_path(@album),
                        :data => {:confirm => "Are you sure?",
                                       :method => "delete"} %></p>

<p><%= link_to "Return to albums", albums_path %></p>

All the code should look familiar except for one key difference. Note that album_song_path takes two arguments, not one: link_to song.name, album_song_path(@album, song). That's because in order to access this path, our application needs both an album's id and a song's id. We pass in the arguments in the same order that they are listed in the path prefix. Our application can handle the rest.

Creating a Song


Next, let's create the controller action for adding a new song:

app/controllers/songs_controller.rb
class SongsController < ApplicationController
  def new
    @album = Album.find(params[:album_id])
    @song = @album.songs.new
    render :new
  end
end

There are two key differences here. First, we pass in album_id in as a parameter. If we look at rake routes, we'll see that the URI Pattern for the new_album_song_path is /albums/:album_id/songs/new. Note that :album_id is the parameter here, not :id. We could also verify the parameters by using binding.pry as well.

The second key difference is that when we instantiate a new Song, we specify @album.songs.new, not Song.new. This way, we can automatically associate the Song instance with the correct Album instance. Other than that, this should look familiar. First, we find the correct album based on the @album passed into the album_song_path. Then we instantiate a new Song and render the corresponding new view.

Next, let's add the view. We'll need a new subdirectory called songs inside of views:

views/songs/new.html.erb
<h1>Add a new song:</h1>

<%= form_for [@album, @song] do |f| %>
  <%= f.label :name %>
  <%= f.text_field :name %>
  <%= f.label :lyrics %>
  <%= f.text_area :lyrics %>
  <p><%= f.submit %></p>
<% end %>

The key difference here is that we have to pass in two objects to form_for instead of just one. Note that we use square brackets to do this. We pass in two objects because our application needs both a song and the album the song will belong to. We also use a text_area instead of a text_field for lyrics so there is more space in the field.

Next, we need a create action in the SongsController:

controllers/songs_controller.rb
...

  def create
    @album = Album.find(params[:album_id])
    @song = @album.songs.new(song_params)
    if @song.save
      redirect_to album_path(@album)
    else
      render :new
    end
  end

  # Other controller actions go here.

  private
    def song_params
      params.require(:song).permit(:name, :lyrics)
    end

...

First, we find an instance of Album based on the album_id property in the params hash. Next, we create a new Song and make sure it's associated with an instance of Album by calling @album.songs.new. Once again, we need to use a params method so we have strong parameters. If the song is properly saved, we'll redirect back to the album the song belongs to.

Showing a Song


The controller action for show is straightforward. We'll just grab a few id parameters and pass them into instance variables:

controllers/songs_controller.rb
def show
  @album = Album.find(params[:album_id])
  @song = Song.find(params[:id])
  render :show
end

We can then use these instance variables to populate our view for showing a specific song. This view includes links to edit and delete the song:

views/songs/show.html.erb
<h1>Song: <%= @song.name %></h1>
<h2>Album: <%= @album.name %></h2>
<h3><%= @song.lyrics %></h3>

<p><%= link_to "Edit song", edit_album_song_path(@album, @song) %></p>
<p><%= link_to "Delete song", album_song_path(@album, @song),
                        :data => {:confirm => "Are you sure?",
                                       :method => "delete"} %></p>

<p><%= link_to "Return to album", album_path(@album) %></p>

Note that we're passing two instance variables into our link_to helper methods. This is expected. We only need one for the album_path, though. The prefix makes it clear which objects need to be passed into the path.

Editing a Song


Next, let's add a controller action for the edit view:

controllers/songs_controller.rb
def edit
  @album = Album.find(params[:album_id])
  @song = Song.find(params[:id])
  render :edit
end

This is straightforward. Once again we need to find the Album instance based on the album_id parameter. Meanwhile, the Song instance is based on the id parameter. This can be verified via rake routes. Finally, we render the edit view.

Our edit view looks pretty much exactly the same as our new view:

views/songs/edit.html.erb
<h1>Edit song:</h1>

<%= form_for [@album, @song] do |f| %>
  <%= f.label :name %>
  <%= f.text_field :name %>
  <%= f.label :lyrics %>
  <%= f.text_area :lyrics %>
  <p><%= f.submit %></p>
<% end %>

Here's the update action in our controller:

controllers/songs_controller.rb
def update
  @song = Song.find(params[:id])
  if @song.update(song_params)
    redirect_to album_path(@song.album)
  else
    @album = Album.find(params[:album_id])
    render :edit
  end
end

The code should look familiar now. In this case, we don't need to grab information about a specific album if the update is successful. We can just update the song itself. We can also pass in @song.album as an argument to album_path. This would also ensure that users would be redirected to the right album if we were to add functionality that allows users to associate a song with a different album. If the update fails, it will render the edit view instead. However, remember that the edit view requires @album to be defined, so the form can correctly render. If @album is undefined, a failing update will crash your application.

Destroying a Song


Finally, here's the code for the destroy action:

app/controllers/songs_controller.rb
def destroy
  @song = Song.find(params[:id])
  @song.destroy
  redirect_to album_path(@song.album)
end

This should look familiar, too. We just need to find the song, not a specific album. Even after the song is destroyed, we still have access to its properties, which means we can still pass @song.album as an argument to album_path.

We've now included full CRUD functionality for songs. Go to the root directory of the project, type in rails s, and navigate to localhost:3000 in the browser to see the changes to our application.

Summary of Key Differences Between Song CRUD and Album CRUD

Before we move on, let's recap the key differences between adding CRUD functionality for a nested object versus adding CRUD functionality for a parent object:

  1. A path for a nested object takes two arguments instead of one. For instance, album_song_path(@album, @song) takes two arguments while album_path(@album) only takes one.
  2. When a URI path is passed into the parameters hash of a controller action, the nested object (in this case, the song) has a key of :id while the parent object has a key of :<name_of_parent_class>_id. Since the parent object is an album, that means the key will be :album_id. We can always verify this by checking rake routes and looking at the value of the URI for a specific route.
  3. When we create a new nested object, we must make an association between the parent object and the nested object. In the example of albums and songs, we can do this with the following code: @album.songs.new. We call #new on @album.songs, not @song. Contrast this with the code we'd write for adding a new album: @album.new.
  4. With a nested object, we need to pass in two objects to a form. We use square brackets for this. That means we need to do form_for [@album, @song] for a nested song. Meanwhile, we only need to pass in an album for the parent object's form helper: form_for @album.

Lesson 12 of 34
Last updated July 14, 2022