Adding dynamic record with Cascading select in rails

This is one of the most common scenarios the developers face every now and then. Adding dynamic child record with cascading dropdown/select. Yes right, two complex mysteries in one episode. This can be achieved in many various ways in rails but I tried with a specific gem and jQuery.

Recently I came across implementing cascading select in rails with adding dynamic child record in parent page. I am assuming we all know how to implement cascading select in rails. if not, then please look no further. Because I am going to write another article on that though tons of similar articles can be found online.

Case scenario

A training will be hosted in different cities of a country. We have to build such a system where admin can select country and then select the city under that country and save the cities with a training record.

My stack

  • Rails: 5.2.4.5
  • Ruby: 2.6.6
  • jQuery: 3.4.1
  • Bootstrap: 4.0.0

The final outcome will be like this. Adding dynamic record with cascading city.

Screen Shot 2021-06-11 at 12.47.20 pm.png

So I used gem cocoon to make the nested forms easier for the child records in one place. First include the gem in the Gemfile.

gem "cocoon"

Bundle install and then add it to the application.js

//= require cocoon

Now it's time to add functionalities to rails.

Models:

class Training < ApplicationRecord
  has_many :cities
  accepts_nested_attributes_for :cities, allow_destroy: true
end

class City < ApplicationRecord
  belongs_to :training
end

Controllers:

class CountriesController < ApplicationController

  def cities
    country = Country.find(params[:id])
    @cities = country.cities

    respond_to do |format|
      format.json {
        render json: {cities: @cities}
      }
    end
  end
end


class TrainingController < ApplicationController

  def create
    @training = Training.new(training_params)
    if @training.save
      redirect_to @training
    else
      render :new
    end
  end

  private

  def training_params
    params.require(:training).permit(:name, :description, cities_attributes: [:id, :city_id, :_destroy])
  end

end

View:

_form.html.erb (Yeah, I know why not slim :) )

<%= form_for training do |form| %>
  <div class="card">
    <div class="card-body">
      <div class="form-group">
        <label class="font-weight-bold">
          Name
        </label>
        <%= form.text_field :name, class: 'form-control'%>
      </div>
      <div class="form-group">
        <label class="font-weight-bold">
          Description
        </label>
        <%= form.text_area :description, class: 'form-control', maxlength: 255 %>
      </div>

      <div>
        <%= form.fields_for :cities do |city| %>
          <div class="row align-items-center pl-2">
            <%= render 'city_fields', f: city, city: city.object %>
          </div>
        <% end %>
        <div class='links mt-3'>
          <%= link_to_add_association 'Add city', form, :cities %>
        </div>
      </div>

    </div>
    <div class="card-footer">
      <div class="card-footer-item text-right">
        <%= link_to 'Cancel', trainings_path, class: 'btn btn-subtle-dark mr-3'%>
        <%= form.submit 'Save', class: 'btn btn-primary'%>
      </div>
    </div>
  </div>
<%- end %>

Partial view to render the set of child inputs (_city_fields.html.erb)

<div class="row pt-3 nested-fields align-items-center">
  <div class="col-auto">
    <%= link_to_remove_association f, class: 'btn btn-sm btn-icon btn-secondary' do %>
      <i class="far fa-trash-alt"></i> <span class="sr-only">Remove</span>
    <%- end %>
  </div>
  <div class="col-auto div-value-source">
    <%= f.select :country_id, options_from_collection_for_select(Country.all, :id, :name, city.try(:country_id)), {prompt: 'Select any'}, class: 'form-control custom-select country' %>
  </div>
  <div class="col-auto div-match-type">
      <%= f.select :city_id, options_for_select([]), {}, class: 'form-control custom-select city' %>
      <%= hidden_field_tag :hdn_city_id, city.id, class: 'hdn_city_id' %>
  </div>
</div>

Note: nested-fields class has to be in the inputs partial view div, otherwise remove link of cocoon won't work.

Now the final and sweet part. Code from jQuery

$(document).on('change', '.country', function(e){
  load_cities($(this));
})

function load_cities(country) {
  $.ajax('/countries/' + country.val() + '/cities', {
    type: 'GET',
    dataType: 'json',
    error: function(jqXHR, textStatus, errorThrown) {
      console.log("AJAX Error: " + textStatus);
    },
    success: function (data) {
      var selected = '';
      var city_select = country.parent().parent().find('.city')
      hdn_city_id = country.parent().parent().find('.hdn_city_id').val()
      city_select.empty();

      $.each(data.cities, function(i, city) {
        selected = hdn_city_id == city.id ? 'selected' : '';
        city_select.append(`<option ${selected} value=${city.id}>${city.display_name}</option>`);
      });
    }
  });
}

I hope this will make your life easy. Thanks for reading the article.