Ruby on Training Wheels

A Weblog for Ruby and Rubyists.

Patterns for Saving Associated Model Data in Rails

This is post explores different ways of saving data associated in some way to the view we’re on.

In each of these examples, we’d like our controller’s create and update actions to remain unchanged:

Default Controller Actions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# ... other actions

# POST /posts
# POST /posts.json
def create
  @post = Post.new(params[:post])

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

# PUT /posts/1
# PUT /posts/1.json
def update
  @post = Post.find(params[:id])

  respond_to do |format|
    if @post.update_attributes(params[:post])
      format.html { redirect_to @post, notice: 'Post was successfully updated.' }
      format.json { head :no_content }
    else
      format.html { render action: "edit" }
      format.json { render json: @post.errors, status: :unprocessable_entity }
    end
  end
end

Post Author

Set up: We have Posts, each of which has an Author (O2M). In the posts#new view, we’d like to choose which user to associate to this post as the author.

We can implement this using a drop-down and the select form helper.

1
2
<%= f.label :author_id %><br />
<%= f.select :author_id, Author.all.collect {|a| [ a.name, a.id ] } %>

Don’t forget to pass :author_id to the Post model’s attr_accessible.


Post Categories

Set up: We have Posts which on turn have many Categories (M2M). In the posts#new view, we’d like to choose multiple categories to associate to this post.

We can implement this using a multi-select. This time, we’ll use the collection_select helper.

1
2
<%= f.label :category_ids, "Categories" %><br />
<%= f.collection_select :category_ids, Category.order(:name), :id, :name, {}, {multiple: true} %>

Don’t forget to pass :category_ids to the Post model’s attr_accessible.

PS: This can be easily improved with the Select2 library to great effect. (Also, see Bonus Section, below.)


Nested Form Within Form

Set up: We have Artists which on turn have many Songs (O2M). In the artists#show view, we’d like to create songs which will automatically be associated with this artist.

Many thanks to Ryan Bates for much of the code in this example.

First, we’ll specify in the Artist model that it can accept form attributed for a Song. We will also tell it that it can destroy song objects in its form (more on that in a minute).

artist.rb
1
2
3
4
5
class Artist < ActiveRecord::Base
  attr_accessible :name, :songs_attributes
  has_many :songs
  accepts_nested_attributes_for :songs, allow_destroy: true
end

Now, when we post fields pertaining to songs together with our artist form data, rails will be able to figure out to create / update the song objects as well.

At this point, in our artist create / update form, we can add nested fields via the fields_for form helper, which we’ll add in a partial.

_form.hmtl.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<%= form_for(@artist) do |f| %>

  <%# error stuff ...  %>

  <div class="field">
    <%= f.label :name %><br />
    <%= f.text_field :name %>
  </div>

  <%= f.fields_for :songs do |builder| %>
    <%= render 'song_fields', f: builder %>
  <% end %>

  <div class="actions">
    <%= f.submit %>
  </div>
<% end %>
_song_fields.html.erb
1
2
3
4
5
6
<fieldset>
  <%= f.label :name, "Song Name" %><br />
  <%= f.text_field :name %><br />
  <%= f.check_box :_destroy %>
  <%= f.label :_destroy, "Remove Song" %>
</fieldset>

If you’re sharp, you’ll notice that there’s this checkbox with a _destroy name. This is a actually a special field in Rails. When this is checked (or otherwise evaluates to true), the associated model will get destroyed. This is specifically enabled by the allow_destroy: true which we passed to the accepts_nested_attributes_for method in artist.rb.

For this to work, we need to do one more thing, in the new action of the ArtistController, we need to add this line:

1
@artist.songs << Song.new

This works, but presents us with some problems. What if we want to create more than one?

Well, we could do this: 3.times {@artist.songs << Song.new}, but that’s not really solving the issue; now we’re stuck creating three songs. We want to be able to add and remove any amount of songs. We also promised not to touch the controllers, a promise we just broke.

In this episode, we see how Ryan solves this problem:

First, remove the code we just added from the ArtistController. We’re not changing any controllers, right?

In the form we edited a bit earlier, leave our changes, but add this:

Link to Add Song
1
<%= link_to_add_fields "Add Song", f, :songs %>

You might be wondering why you never saw the link_to_add_fields method before. You haven’t because it doesn’t exist yet.

Lets create it. In your application_helper.rb file, add this code:

1
2
3
4
5
6
7
8
def link_to_add_fields(name, f, association)
  new_object = f.object.send(association).klass.new
  id = new_object.object_id
  fields = f.fields_for(association, new_object, child_index: id) do |builder|
    render(association.to_s.singularize + "_fields", f: builder)
  end
  link_to(name, '#', class: "add_fields", data: {id: id, fields: fields.gsub("\n", "")})
end

In a nutshell, this creates a link that embeds the Song form in its data attribute the way it exists in a _?_fields.html.erb partial, where ? is what you passed in as the third param, i.e. songs. We created this partial already, but this method will work for any nested resource, so long that you name the partial correctly.

Once that done, all we have left to do is add the JS that will allow us to append the embedded form in to the DOM. We’ll also change the delete field from a checkbox to a hidden field. Instead, we’ll implement a “Remove Song” link, tied to which a “click” event handler will hide the form, and set the _destroy hidden field to true.

Let’s do it!

artist.js.coffee
1
2
3
4
5
6
7
8
9
10
11
jQuery ->
  $('form').on 'click', '.remove_fields', (event) ->
    $(this).prev('input[type=hidden]').val('1')
    $(this).closest('fieldset').hide()
    event.preventDefault()

  $('form').on 'click', '.add_fields', (event) ->
    time = new Date().getTime()
    regexp = new RegExp($(this).data('id'), 'g')
    $(this).before($(this).data('fields').replace(regexp, time))
    event.preventDefault()

And our modified _song_fields.html.erb partial:

1
2
3
4
5
6
<fieldset>
  <%= f.label :name, "Card Name" %><br />
  <%= f.text_field :name %><br />
  <%= f.hidden_field :_destroy %>
  <%= link_to "Remove Card", '#', class: "remove_fields" %>
</fieldset>

And now, everything should work.


Bonus: Add New if Doesn’t Exist with Select2

This final example is a bit of a hack, and is merely used as an exercise.

Set up: We have Recipes which on turn have many Ingredients (M2M). In the recipes#new view, we’d like to choose multiple ingredients to associate with this recipe, creating new ones if they don’t exist. This assumes that ingredient names are unique.

This example depends on the Select2 library, so download it and and put all it’s files in your assets directory, paying attention that any images referenced in the CSS will be found.

In order to be able to use the Select2 createSearchChoice method (necessary to add new items) we’ll need to operate on a hidden field. This will break the form for users that have JS disabled.

Because the hidden field will submit a string of separated ids, plus new names, and the ingredient_ids \ ingredient_ids= methods expect an array, we will overwrite these methods in our model:

recipe.rb
1
2
3
4
5
6
7
8
9
# Join our array to string, separating with an arbitrary, unlikely-to-be-used string
def ingredient_ids
  super.join("%%$$%%")
end

# Put it back into an array, saving any non-integer strings as a new Ingredient
def ingredient_ids=(ids)
  super ids.split("%%$$%%").map{|id| id.is_a?(Integer) || id.gsub(/\d+/, '') == "" ? id : Ingredient.create(name: id).id }
end

Now let’s add the hidden field to our form:

_form.html.erb
1
2
  <%= f.label :ingredient_ids, "Ingredients" %><br />
  <%= f.hidden_field :ingredient_ids, data: {ingredients: Ingredient.order(:name).map{|i|i.as_json only: [:id,:name]}} %>

And, finally, the CoffeeScript:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
jQuery ->
  format = (item) -> item.name

  $('#recipe_ingredient_ids').select2
    data:
      results: $('#recipe_ingredient_ids').data "ingredients"
      text: 'name'
    formatSelection: format
    formatResult: format
    multiple: true
    separator: '%%$$%%'
    createSearchChoice: (term) ->
      id: term,
      name: term + ' (new)'

This will populate the Select2 field with data that was embedded into the hidden field’s ingredients data attribute. It will also use the name property, as the text property that Select2 requires.

See more details on the Select2 Docs page.


That about wraps it up. Let me know if I missed any useful pattern!

1239: The social media reaction to this asteroid announcement has been sharply negative. Care to respond?


All Revisions to this document are visible here