Cocoon allows to easily add nested models in a form, but with some javascript, it allows further enhancement and a better experience for your users.

Nathan Van der Auwera’s Cocoon gem is a favourite here at CookiesHQ. It allows to create nested forms in a super-easy way, saving us a lot of time and trouble. It relies on jQuery and works on top of SimpleForm and Formtastic, so with its extensive documentation it is really easy to set up and use.

However, sometimes you need some extra tinkering to get a form working the way it is meant to. In this case, we needed our nested form to offer certain options in a select, but the allowed options depended on the previously selected values. Here’s how we used Cocoon’s callbacks to get the desired behaviour.

Given our nested form (built with SimpleForm):

...
.allocated-mentors
  = f.simple_fields_for :allocated_mentors do |allocated_mentor|
    = render 'allocated_mentor_fields', f: allocated_mentor, mentors: mentors
  .links
    = link_to_add_association 'Add mentor', f, :allocated_mentors, render_options: { locals: { mentors: mentors } }
...

And our nested fields partial:

.nested-fields.allocated-mentor
  = f.association :mentor, as: :select, label: false, prompt: 'Select a mentor', collection: mentors, input_html: {class: "mentor-options" }
  = link_to_remove_association "Remove mentor", f

The above results in an input that allows the user to select a mentor from a list, but it also to the same mentor to be chosen again each time we used the link_to_add_association. This shouldn’t be possible according to our requirements.

What we needed was to disable the already selected mentors from the selects on the partials we added using the add link. For that, we used before-insert callback:

$('.allocated-mentors').on('cocoon:before-insert', (e, mentorToBeAdded) ->
    assignedMentors = []
    $(this).find('.nested-fields select').each ->
      assignedMentors.push $(this).val()

    $.each assignedMentors, (index, mentorId) ->
      if mentorId != ''
        mentorToBeAdded.find("option[value='#{mentorId}']").attr('disabled', true)

  )

Each time we insert a new nested form, we collect the already selected mentors and disabled them from the inserted form select.

We also needed to cover the case in which the user would first press ‘Add new mentor’ several times, and then start selecting the mentors. Obviously, when a mentor is selected, it should be disabled in the rest of select inputs:

$(".mentor-options").change (e) ->
  assignedMentors = []
  $(this).parents('.nested-fields').find('select').each ->
    thisSelect = $(this)
    if thisSelect.val() != ''
      assignedMentors.push thisSelect.val()

    if assignedMentors.length == 0
      thisSelect.parents('.nested-fields').siblings().find("option").attr('disabled', false)
    else
      $.each assignedMentors, (index, mentorId) ->
        thisSelect.parents('.nested-fields').siblings().find("option[value='#{mentorId}']").attr('disabled', true)

Again, we collect the mentors that are selected and disable them in the rest of selects, and just in case we have no selected mentors, we make sure that all options are enabled on the remaining selects. This possibility, and the fact that we could have users removing nested forms via the ‘Remove mentor’ had us extend our Cocoon callbacks to get the inverse effect: whenever we remove a mentor, we should have it enabled again on the rest of select inputs.

This way, our first piece of CoffeeScript code had to be modified to chain the before-remove callback:

$('.allocated-mentors').on('cocoon:before-insert', (e, mentorToBeAdded) ->
    assignedMentors = []
    $(this).find('.nested-fields select').each ->
      assignedMentors.push $(this).val()

    $.each assignedMentors, (index, mentorId) ->
      if mentorId != ''
        mentorToBeAdded.find("option[value='#{mentorId}']").attr('disabled', true)

).on('cocoon:before-remove', (e, mentorToBeRemoved) ->
  mentorToRestore = mentorToBeRemoved.find('select').val()

  $(this).find('.nested-fields select').each ->
    $(this).find("option[value='#{mentorToRestore}']").attr('disabled', false)
)

Please note that you need to chain the callbacks, as explained in the gem’s documentation, you can’t do it separately:

$('.allocated-mentors').on 'cocoon:before-insert', (e, mentorToBeAdded) ->
  ...

$('.allocated-mentors').on 'cocoon:before-remove', (e, mentorToBeRemoved) ->
  ...

If you do it this way, the before-insert block won’t work!

Summary

I hope this post illustrates a tiny bit of the powerful things that you can achieve with Cocoon and some Coffeescript and encourages you to give it a try. Thoughts or advice? Leave us a comment!

Photo ‘Silkworm cocoons’ by Sarah MacMillan on Flickr, used under (CC BY-NC-SA 2.0) license