I18n Country & City Select Fields - Reconstructing Carmen-Rails from Scratch

One would think that Country/City selection fields are something so common that someone must have created a simple method to implement it. That may true to a point, but it turns out the City-State gem only provides English names, while the Carmen-Rails gem  - despite having I18n support - due to its dependence on the Carmen gem(https://github.com/carmen-ruby/carmen) - is no longer actively maintained.

So, there's a few ways to get a working I18n Country/City select field working. The simplest might be to unpack the Carmen-Rails gem, change it to support Rails 5 with this fix, then install it by telling the Gemfile to install from local file. I did that initially,  but that didn't feel exactly right. So, I decided to reinvent the wheel and write the code from scratch.

Now there are other gems that return country/subregions information with I18n support, such as the Countries gem and the Cities gem. However, for the purpose of making select fields, I found these gems return way too much information about the region, which causes the performance to take a hit (though I suppose this could be relieved by caching the data). For my purpose, I will be using the Carmen gem, which returns only the basic information of the countries/cities.

A few things of note: despite Carmen's I18n support, some countries still lack translations, and only the most common locales are supported. Therefore, I have supplemented Carmen with data extracted from I18n-country-translations gem. Another thing that is less than ideal is that the cities in the Carmen gem seem to support the English locale only, my get-around for that is to translate manually (by myself) only the cities of the countries that I expect the users to be from and leave all other cities to default to their English translations.

PS. If anyone know of anyway to get translated city names of most major locales, please comment! I suppose one could query Google Maps - which seem to require one request per city, or Wikidata - which only support limited locales; or GeoNames - which I have no idea how to associate each of the alternative names with its language.

So, Let's get to it.

----

First thing is to install the Carmen gem (assuming you already have the rails-i18n set-up):
1
gem 'carmen'

Since I will be using some customized translations later, I created 'carmen.rb' in the config folder, and add this line per the docs instruction, which will pass the files inside the config/locale folder to Carmen:
1
Carmen.i18n_backend.append_locale_path(Rails.root.join('config', 'locales'))

In application_controller.rb, I wrote this method which generates the options array for countries sorted by their name:
1
2
3
4
5
6
7
  def country_options(pref_countries)
    country_options = []
    countries = Carmen::Country.all
    codes = pref_countries + countries.sort_by{|c| c.name}.map(&:alpha_2_code)
    codes.each{|c| country_options.push([countries.coded(c).name, c] )}
    country_options.insert(pref_countries.length, ["---------------", "nil"])
  end

Now, in my user_controller.rb, I've set the locale for Carmen, and added @country_options in the appropriate actions. I also have to add a subregion_options action for updating the city fields when the country selected changes:
 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
 # @country_options can of course be refactored or cached
 # with the pref_countries array -  ['CN', HK'], 
 # China(CN) and Hong Kong(HK) will now appear 
 # at the top of the list (and duplicated in the main list)      

  def new
    @user = User.new
    @country_options = country_options(['CN', 'HK']) 
  end

  def edit
    @user = User.find(params[:id])
    @country_options = country_options(['CN', 'HK']) 
  end

  # for rendering city options after user selects the 'country' field in edit profile form
  def subregion_options
    render partial: 'subregion_select'
  end
  
  private
    # sets locale for country-city selection display
    def set_carmen_locale
      locale = I18n.locale
      Carmen.i18n_backend.locale = locale if locale
    end

Finally, the form:
 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
    <%= form_for @user, html: {id: "main_form"} do |f| %>
      ...
      <div class="field">
        <%= f.label :country %><br />
        <% user_country = Carmen::Country.coded(@user.country) %>
        <% selected = (user_country.nil?) ? nil : user_country.alpha_2_code %>
        <%= select_tag :"user[country]", options_for_select(@country_options, {selected: selected, disabled: "nil"}), include_blank: true  %>
      </div>

      <%# the city field is rendered separately as it is updated according to the country selected %>
      <div class="field" id="city_field">
        <%= render partial: 'users/subregion_select', locals: {parent_region: f.object.country}, class: "form-control" %>
      </div>
...
      <div><%= f.submit "update", class:"btn btn-primary button_submit" %></div>
   <% end %>


<%# script for updating city field after country changes, can be put in a separate js file %>
<script>
  $(function() {
    $('select#user_country').change(function(event) {
      $('#city_label').hide();
      var select_wrapper = $('#user_city_code_wrapper')
      $('select', select_wrapper).attr('disabled', true)
      country_code = $(this).val()
      locale = $('.locale').data('locale')
      url = "/users/subregion_options?parent_region=" + country_code + "&locale=" + locale
      $('#city_field').load(url)
    }) 
  })
</script>
(Note that my User model has the attribute 'country' and 'city' which is used to pre-select a field in the edit action.)

The partial used to render the city fields:
views/users/subregion_select.html.erb
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<div id="user_city_code_wrapper">
  <% parent_region ||= params[:parent_region] %>
  <% country = Carmen::Country.coded(parent_region) %>

  <% if country.nil? %>
    <%= label_tag :city, t('activerecord.attributes.user.city') %><br />
    <em>Please select a country above</em>
  <% elsif country.subregions? %>
    <%= label_tag :city, t('activerecord.attributes.user.city') %><br />

    <% city = country.subregions.coded(@user.city) %>
    <% selected = (city.nil?) ? nil : city.code %>
    <%= select_tag :"user[city]", options_for_select(country.subregions.sort_by{|c| c.name}.map{|c| [c.name, c.code]}, selected), include_blank: true %>
  <% else %>
    <%= hidden_field_tag 'user[city]', 'none' %>
  <% end %>
</div>
(I have made the choice to hide the city field if the country selected do not have cities; and this would submit the form with the value 'none' for the city attribute)

And add the action to config/routes.rb:
1
get 'users/subregion_options', to: 'users#subregion_options'

If everything's right, the country and city fields should now work!

 ---

Additional tweaks:
1. Customizing translated names
As mentioned, I decided to use the I18n-countries-translation data to fill in some untranslated country names. Instead of installing the gem though, I figured I will pull out the data in include it manually in my locale files to overwrite Carmen. (The files can be downloaded here: https://github.com/onomojo/i18n-country-translations/tree/master/rails/locale/iso_639-1 As you can see, if offers much more locales than Carmen does.)

One problem though is that Carmen requires the locales formatted like so in the YML:
1
2
3
4
en:
  world:
    us:
      official_name: These Crazy States

So, here's some simple code I wrote to convert the file(original.yml) from I18n-countries-translation to the format required (as converted.yml):
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
text = File.open('original.yml').read
text = text.downcase.split(/\n/)
output = ''
text.each_with_index do |c, index|
  if index < 2
    output += "#{c}\n" 
  elsif index == 2
    output += "  world:\n"
  else
    c = c.split(/: /)
    output += "#{c[0]}:\n      name: #{c[1]}\n"
  end
end
File.open('converted.yml', 'w'){|f| f.write output}

2. Including phone codes in the select fields
As I develop my app, I realized I needed another country select field, but this time with the phone codes included at the end, and the value to be the phone code instead of the country code. JSON files that map country(codes) to phone codes can be found here: https://stackoverflow.com/questions/2530377/list-of-phone-number-country-codes

I made one myself to fit my own needs:
config/locales/phone_codes.json  (Due to a lack of a proper place to put this, I just put it in the locales folder)
1
{"BD":"880","BE":"32","BF":"226","BG":"359","BA":"387","BB":"1-246","WF":"681","BL":"590","BM":"1-441","BN":"673","BO":"591","BH":"973","BI":"257","BJ":"229","BT":"975","JM":"1-876","BV":"47","BW":"267","WS":"685","BQ":"599","BR":"55","BS":"1-242","JE":"44-1534","BY":"375","BZ":"501","RU":"7","RW":"250","RS":"381","TL":"670","RE":"262","TM":"993","TJ":"992","RO":"40","TK":"690","GW":"245","GU":"1-671","GT":"502","GS":"500","GR":"30","GQ":"240","GP":"590","JP":"81","GY":"592","GG":"44-1481","GF":"594","GE":"995","GD":"1-473","GB":"44","GA":"241","SV":"503","GN":"224","GM":"220","GL":"299","GI":"350","GH":"233","OM":"968","TN":"216","JO":"962","HR":"385","HT":"509","HU":"36","HK":"852","HN":"504","HM":"672","VE":"58","PR":"1-787","PS":"970","PW":"680","PT":"351","SJ":"47","PY":"595","IQ":"964","PA":"507","PF":"689","PG":"675","PE":"51","PK":"92","PH":"63","PN":"870","PL":"48","PM":"508","ZM":"260","EH":"212","EE":"372","EG":"20","ZA":"27","EC":"593","IT":"39","VN":"84","SB":"677","ET":"251","SO":"252","ZW":"263","SA":"966","ES":"34","ER":"291","ME":"382","MD":"373","MG":"261","MF":"590","MA":"212","MC":"377","UZ":"998","MM":"95","ML":"223","MO":"853","MN":"976","MH":"692","MK":"389","MU":"230","MT":"356","MW":"265","MV":"960","MQ":"596","MP":"1-670","MS":"1-664","MR":"222","IM":"44-1624","UG":"256","TZ":"255","MY":"60","MX":"52","IL":"972","FR":"33","IO":"246","SH":"290","FI":"358","FJ":"679","FK":"500","FM":"691","FO":"298","NI":"505","NL":"31","NO":"47","NA":"264","VU":"678","NC":"687","NE":"227","NF":"672","NG":"234","NZ":"64","NP":"977","NR":"674","NU":"683","CK":"682","XK":"383","CI":"225","CH":"41","CO":"57","CN":"86","CM":"237","CL":"56","CC":"61","CA":"1","CG":"242","CF":"236","CD":"243","CZ":"420","CY":"357","CX":"61","CR":"506","CW":"599","CV":"238","CU":"53","SZ":"268","SY":"963","SX":"599","KG":"996","KE":"254","SS":"211","SR":"597","KI":"686","KH":"855","KN":"1-869","KM":"269","ST":"239","SK":"421","KR":"82","SI":"386","KP":"850","KW":"965","SN":"221","SM":"378","SL":"232","SC":"248","KZ":"7","KY":"1-345","SG":"65","SE":"46","SD":"249","DO":"1-809","DM":"1-767","DJ":"253","DK":"45","VG":"1-284","DE":"49","YE":"967","DZ":"213","US":"1","UY":"598","YT":"262","UM":"1","LB":"961","LC":"1-758","LA":"856","TV":"688","TW":"886","TT":"1-868","TR":"90","LK":"94","LI":"423","LV":"371","TO":"676","LT":"370","LU":"352","LR":"231","LS":"266","TH":"66","TF":"262","TG":"228","TD":"235","TC":"1-649","LY":"218","VA":"379","VC":"1-784","AE":"971","AD":"376","AG":"1-268","AF":"93","AI":"1-264","VI":"1-340","IS":"354","IR":"98","AM":"374","AL":"355","AO":"244","AQ":"672","AS":"1-684","AR":"54","AU":"61","AT":"43","AW":"297","IN":"91","AX":"358-18","AZ":"994","IE":"353","ID":"62","UA":"380","QA":"974","MZ":"258"}

And tweaked my helper method:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
  def country_options(pref_countries, include_phone = false)
    country_options = []
    countries = Carmen::Country.all
    phone_codes = JSON.parse(File.read('config/locales/phone_codes.json'))

    codes = pref_countries + countries.sort_by{|c| c.name}.map(&:alpha_2_code)
    if include_phone
      codes.each{|c| country_options.push(["#{countries.coded(c).name} (+#{phone_codes[c]})", "#{phone_codes[c]}"] )}
    else
      codes.each{|c| country_options.push([countries.coded(c).name, c] )}
    end
    country_options.insert(pref_countries.length, ["---------------", "nil"])
  end

Now, calling the helper with the option 'include_phone = true':
1
@phone_options = country_options(['CN', 'HK'], include_phone = true) 
gives me select fields with the phone code!

Comments

Popular posts from this blog

(Re: Pagination, You're doing it wrong) - Fixing Duplicate/Missing Records in Infinite Scrolling + Pagination by Hacking the Kaminari Gem

Sending an Email Confirmation Link with the Right Locale (with Devise)