country_state_select 3.2.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +41 -0
  3. data/.gitignore +2 -0
  4. data/.rspec +1 -0
  5. data/CHANGELOG.md +56 -0
  6. data/README.md +196 -111
  7. data/Rakefile +26 -0
  8. data/app/controllers/country_state_select/cscs_controller.rb +31 -8
  9. data/app/javascript/country_state_select/controller.js +66 -0
  10. data/app/javascript/country_state_select/country_state_select.js +245 -0
  11. data/config/importmap.rb +4 -0
  12. data/config/routes.rb +14 -4
  13. data/country_state_select.gemspec +14 -4
  14. data/docs/cities.md +35 -0
  15. data/docs/images/cascading-select-demo.png +0 -0
  16. data/docs/json-api.md +41 -0
  17. data/docs/migration-v4.md +32 -0
  18. data/gemfiles/rails_7.0.gemfile +10 -0
  19. data/gemfiles/rails_7.1.gemfile +7 -0
  20. data/gemfiles/rails_7.2.gemfile +7 -0
  21. data/gemfiles/rails_8.0.gemfile +7 -0
  22. data/lib/country_state_select/configuration.rb +89 -0
  23. data/lib/country_state_select/country_metadata.rb +29 -0
  24. data/lib/country_state_select/data/dial_codes.yml +250 -0
  25. data/lib/country_state_select/data_sources/base.rb +27 -0
  26. data/lib/country_state_select/data_sources/city_state.rb +28 -0
  27. data/lib/country_state_select/engine.rb +31 -0
  28. data/lib/country_state_select/form_builder.rb +48 -0
  29. data/lib/country_state_select/localization.rb +41 -0
  30. data/lib/country_state_select/version.rb +1 -1
  31. data/lib/country_state_select.rb +83 -23
  32. data/lib/generators/country_state_select/install/install_generator.rb +22 -0
  33. data/lib/generators/country_state_select/install/templates/README +34 -0
  34. data/lib/generators/country_state_select/install/templates/initializer.rb +29 -0
  35. data/package.json +40 -0
  36. data/spec/country_state_select_spec.rb +2 -2
  37. data/spec/lib/country_state_select/configuration_spec.rb +63 -0
  38. data/spec/lib/country_state_select/country_metadata_spec.rb +42 -0
  39. data/spec/lib/country_state_select/filtering_spec.rb +68 -0
  40. data/spec/lib/country_state_select/localization_spec.rb +30 -0
  41. data/spec/requests/business_form_spec.rb +38 -0
  42. data/spec/requests/lookups_spec.rb +72 -0
  43. data/spec/spec_helper.rb +12 -4
  44. data/vendor/assets/javascript/country_state_select.js.erb +35 -21
  45. data/vendor/assets/javascript/country_state_select_vanilla.js +252 -0
  46. metadata +147 -17
  47. data/.circleci/config.yml +0 -15
  48. data/.travis.yml +0 -14
  49. data/lib/country_state_select/engine3.rb +0 -10
  50. data/lib/country_state_select/railtie.rb +0 -10
data/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "country_state_select",
3
+ "version": "4.0.0",
4
+ "description": "Dependency-free Country/State/City cascading select fields, with an optional Stimulus controller. JS companion to the country_state_select Ruby gem.",
5
+ "type": "module",
6
+ "main": "app/javascript/country_state_select/country_state_select.js",
7
+ "module": "app/javascript/country_state_select/country_state_select.js",
8
+ "exports": {
9
+ ".": "./app/javascript/country_state_select/country_state_select.js",
10
+ "./controller": "./app/javascript/country_state_select/controller.js"
11
+ },
12
+ "files": [
13
+ "app/javascript"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/arvindvyas/Country-State-Select.git"
18
+ },
19
+ "homepage": "https://github.com/arvindvyas/Country-State-Select",
20
+ "bugs": "https://github.com/arvindvyas/Country-State-Select/issues",
21
+ "keywords": [
22
+ "rails",
23
+ "country",
24
+ "state",
25
+ "city",
26
+ "select",
27
+ "stimulus",
28
+ "hotwire"
29
+ ],
30
+ "author": "Arvind Vyas",
31
+ "license": "MIT",
32
+ "peerDependencies": {
33
+ "@hotwired/stimulus": ">=3.0.0"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "@hotwired/stimulus": {
37
+ "optional": true
38
+ }
39
+ }
40
+ }
@@ -164,9 +164,9 @@ describe CountryStateSelect do
164
164
  expect(city_without_country).to eq(city_with_country)
165
165
  end
166
166
 
167
- it 'returns an empty array if there are no states in that Country' do
167
+ it 'returns an empty array if there are no cities for that state/country' do
168
168
  method_call = CountryStateSelect.collect_cities('', '')
169
- expect(method_call).to eq(nil)
169
+ expect(method_call).to eq([])
170
170
  end
171
171
  end
172
172
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe CountryStateSelect::Configuration do
6
+ describe '#data_source_instance' do
7
+ it 'defaults to the CityState adapter' do
8
+ expect(CountryStateSelect.configuration.data_source_instance).to be_a(CountryStateSelect::DataSources::CityState)
9
+ end
10
+
11
+ it 'memoizes the instance across calls' do
12
+ config = CountryStateSelect::Configuration.new
13
+
14
+ expect(config.data_source_instance).to equal(config.data_source_instance)
15
+ end
16
+
17
+ it 'rebuilds the instance when the data source is reassigned' do
18
+ config = CountryStateSelect::Configuration.new
19
+ first = config.data_source_instance
20
+
21
+ config.data_source = :city_state
22
+ expect(config.data_source_instance).not_to equal(first)
23
+ end
24
+
25
+ it 'raises for an unknown symbol data source' do
26
+ config = CountryStateSelect::Configuration.new
27
+ config.data_source = :not_a_real_source
28
+
29
+ expect { config.data_source_instance }.to raise_error(ArgumentError)
30
+ end
31
+
32
+ it 'accepts a custom adapter object directly' do
33
+ custom = Class.new(CountryStateSelect::DataSources::Base) do
34
+ def countries = { US: 'United States' }
35
+ end.new
36
+ config = CountryStateSelect::Configuration.new
37
+ config.data_source = custom
38
+
39
+ expect(config.data_source_instance).to equal(custom)
40
+ end
41
+ end
42
+
43
+ describe 'defaults' do
44
+ it 'has sane defaults for a fresh configuration' do
45
+ config = CountryStateSelect::Configuration.new
46
+
47
+ expect(config.priority_countries).to eq([])
48
+ expect(config.flags).to be(false)
49
+ expect(config.dial_codes).to be(false)
50
+ expect(config.localize_names).to be(false)
51
+ expect(config.cache_duration).to eq(86_400)
52
+ expect(config.draw_routes).to be(true)
53
+ end
54
+ end
55
+
56
+ describe '.configure' do
57
+ it 'yields the shared configuration and persists changes' do
58
+ CountryStateSelect.configure { |c| c.priority_countries = %w[US] }
59
+
60
+ expect(CountryStateSelect.configuration.priority_countries).to eq(%w[US])
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe CountryStateSelect::CountryMetadata do
6
+ describe '.flag' do
7
+ it 'converts a two-letter ISO code into its regional indicator flag emoji' do
8
+ expect(described_class.flag(:US)).to eq("\u{1F1FA}\u{1F1F8}")
9
+ expect(described_class.flag('IN')).to eq("\u{1F1EE}\u{1F1F3}")
10
+ end
11
+
12
+ it 'returns nil for a non two-letter code' do
13
+ expect(described_class.flag('USA')).to be_nil
14
+ end
15
+ end
16
+
17
+ describe '.decorate' do
18
+ after { CountryStateSelect.reset_configuration! }
19
+
20
+ it 'returns the plain name when flags and dial_codes are both off' do
21
+ expect(described_class.decorate(:US, 'United States')).to eq('United States')
22
+ end
23
+
24
+ it 'prepends the flag when configured' do
25
+ CountryStateSelect.configure { |c| c.flags = true }
26
+
27
+ expect(described_class.decorate(:US, 'United States')).to start_with("\u{1F1FA}\u{1F1F8}")
28
+ end
29
+
30
+ it 'appends the dial code when configured' do
31
+ CountryStateSelect.configure { |c| c.dial_codes = true }
32
+
33
+ expect(described_class.decorate(:US, 'United States')).to eq('United States (+1)')
34
+ end
35
+
36
+ it 'combines flag and dial code when both are configured' do
37
+ CountryStateSelect.configure { |c| c.flags = true; c.dial_codes = true }
38
+
39
+ expect(described_class.decorate(:US, 'United States')).to eq("\u{1F1FA}\u{1F1F8} United States (+1)")
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe 'CountryStateSelect only/except/priority filtering' do
6
+ after { CountryStateSelect.reset_configuration! }
7
+
8
+ describe '.countries_collection' do
9
+ it 'restricts to only the given codes when passed explicitly' do
10
+ result = CountryStateSelect.countries_collection(only: %w[US IN])
11
+
12
+ expect(result.map(&:last)).to contain_exactly(:US, :IN)
13
+ end
14
+
15
+ it 'excludes the given codes when passed explicitly' do
16
+ result = CountryStateSelect.countries_collection(except: %w[US])
17
+
18
+ expect(result.map(&:last)).not_to include(:US)
19
+ end
20
+
21
+ it 'moves priority codes to the front, in the given order' do
22
+ result = CountryStateSelect.countries_collection(priority: %w[IN US])
23
+
24
+ expect(result.first(2).map(&:last)).to eq(%i[IN US])
25
+ end
26
+
27
+ it 'reads only/except/priority from global configuration when not passed explicitly' do
28
+ CountryStateSelect.configure do |c|
29
+ c.only_countries = %w[US IN GB]
30
+ c.priority_countries = %w[GB]
31
+ end
32
+
33
+ result = CountryStateSelect.countries_collection
34
+
35
+ expect(result.first.last).to eq(:GB)
36
+ expect(result.map(&:last)).to contain_exactly(:US, :IN, :GB)
37
+ end
38
+
39
+ it 'lets a per-call override win over global configuration' do
40
+ CountryStateSelect.configure { |c| c.only_countries = %w[US] }
41
+
42
+ result = CountryStateSelect.countries_collection(only: %w[IN])
43
+
44
+ expect(result.map(&:last)).to contain_exactly(:IN)
45
+ end
46
+ end
47
+
48
+ describe '.collect_states / .raw_states' do
49
+ it 'returns [name, code] pairs from collect_states and [code, name] from raw_states' do
50
+ labeled = CountryStateSelect.collect_states('US')
51
+ raw = CountryStateSelect.raw_states('US')
52
+
53
+ expect(labeled.first).to eq(raw.first.reverse)
54
+ end
55
+
56
+ it 'applies only/except/priority the same way as countries' do
57
+ result = CountryStateSelect.collect_states('US', only: %w[CA NY])
58
+
59
+ expect(result.map(&:last)).to contain_exactly(:CA, :NY)
60
+ end
61
+ end
62
+
63
+ describe '.countries_except' do
64
+ it 'is equivalent to countries_collection(except: ...)' do
65
+ expect(CountryStateSelect.countries_except('US')).to eq(CountryStateSelect.countries_collection(except: ['US']))
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe CountryStateSelect::Localization do
6
+ after do
7
+ CountryStateSelect.reset_configuration!
8
+ I18n.backend.reload!
9
+ end
10
+
11
+ describe '.country_name' do
12
+ it 'returns the English name unchanged when localize_names is off' do
13
+ expect(described_class.country_name(:FR, 'France')).to eq('France')
14
+ end
15
+
16
+ context 'when localize_names is on' do
17
+ before { CountryStateSelect.configure { |c| c.localize_names = true } }
18
+
19
+ it 'falls back to the English name when no translation is available' do
20
+ expect(described_class.country_name(:FR, 'France')).to eq('France')
21
+ end
22
+
23
+ it 'uses a country_state_select.countries.<CODE> I18n key when present' do
24
+ I18n.backend.store_translations(:en, country_state_select: { countries: { FR: 'La France' } })
25
+
26
+ expect(described_class.country_name(:FR, 'France')).to eq('La France')
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe 'FormBuilder integration (via the dummy Business form)', type: :request do
6
+ describe 'GET /businesses/new' do
7
+ it 'renders a country select and blank state/city text fields' do
8
+ get '/businesses/new'
9
+
10
+ expect(response.body).to match(/<select[^>]*id="business_country"/)
11
+ expect(response.body).to include('United States')
12
+ expect(response.body).to match(/<input[^>]*type="text"[^>]*id="business_state"/)
13
+ expect(response.body).to match(/<input[^>]*type="text"[^>]*id="business_city"/)
14
+ end
15
+
16
+ it 'stamps the Stimulus target data attributes' do
17
+ get '/businesses/new'
18
+
19
+ expect(response.body).to include('data-country-state-select-target="country"')
20
+ expect(response.body).to include('data-country-state-select-target="state"')
21
+ expect(response.body).to include('data-country-state-select-target="city"')
22
+ end
23
+ end
24
+
25
+ describe 'GET /businesses/:id/edit' do
26
+ let(:business) { Business.create!(name: 'Golden Gate Cafe', country: 'US', state: 'CA', city: 'San Francisco') }
27
+
28
+ it 'renders state and city as populated selects with the saved value selected' do
29
+ get "/businesses/#{business.id}/edit"
30
+
31
+ expect(response.body).to match(/<select[^>]*id="business_state"/)
32
+ expect(response.body).to match(/<option selected="selected" value="CA">California<\/option>/)
33
+
34
+ expect(response.body).to match(/<select[^>]*id="business_city"/)
35
+ expect(response.body).to match(/<option selected="selected" value="San Francisco">San Francisco<\/option>/)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe 'CountryStateSelect lookup endpoints', type: :request do
6
+ describe 'GET /find_states' do
7
+ it 'returns raw [code, name] pairs for the given country' do
8
+ get '/find_states', params: { country_id: 'US' }, as: :json
9
+
10
+ expect(response).to have_http_status(:ok)
11
+ body = JSON.parse(response.body)
12
+ expect(body).to include(%w[CA California])
13
+ end
14
+
15
+ it 'returns an empty array for a country with no states' do
16
+ get '/find_states', params: { country_id: 'MF' }, as: :json
17
+
18
+ expect(JSON.parse(response.body)).to eq([])
19
+ end
20
+
21
+ it 'sets Cache-Control based on the configured cache_duration' do
22
+ CountryStateSelect.configure { |c| c.cache_duration = 3600 }
23
+
24
+ get '/find_states', params: { country_id: 'US' }, as: :json
25
+
26
+ expect(response.headers['Cache-Control']).to include('max-age=3600')
27
+ expect(response.headers['ETag']).to be_present
28
+ end
29
+
30
+ it 'does not mark the response public when cache_duration is disabled' do
31
+ CountryStateSelect.configure { |c| c.cache_duration = nil }
32
+
33
+ get '/find_states', params: { country_id: 'US' }, as: :json
34
+
35
+ expect(response.headers['Cache-Control']).not_to include('public')
36
+ end
37
+
38
+ it 'returns 304 on a conditional request with a matching ETag' do
39
+ get '/find_states', params: { country_id: 'US' }, as: :json
40
+ etag = response.headers['ETag']
41
+
42
+ get '/find_states', params: { country_id: 'US' }, headers: { 'If-None-Match' => etag }, as: :json
43
+
44
+ expect(response).to have_http_status(:not_modified)
45
+ end
46
+
47
+ it 'applies only/except/priority filtering configured on the data source' do
48
+ CountryStateSelect.configure { |c| c.priority_countries = [] }
49
+
50
+ get '/find_states', params: { country_id: 'US' }, as: :json
51
+ codes = JSON.parse(response.body).map(&:first)
52
+
53
+ expect(codes).to include('CA', 'NY')
54
+ expect(codes).not_to include('COUNTRY_ISO_CODE')
55
+ end
56
+ end
57
+
58
+ describe 'GET /find_cities' do
59
+ it 'returns city names for the given state and country' do
60
+ get '/find_cities', params: { state_id: 'mp', country_id: 'in' }, as: :json
61
+
62
+ expect(response).to have_http_status(:ok)
63
+ expect(JSON.parse(response.body)).to be_an(Array)
64
+ end
65
+
66
+ it 'returns an empty array when state_id is blank' do
67
+ get '/find_cities', params: { state_id: '', country_id: 'us' }, as: :json
68
+
69
+ expect(JSON.parse(response.body)).to eq([])
70
+ end
71
+ end
72
+ end
data/spec/spec_helper.rb CHANGED
@@ -1,9 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'rails'
4
- require 'active_record'
5
- require 'country_state_select'
3
+ ENV['RAILS_ENV'] ||= 'test'
4
+ require File.expand_path('dummy/config/environment', __dir__)
5
+ require 'rspec/rails'
6
+
7
+ ActiveRecord::Migration.maintain_test_schema!
6
8
 
7
9
  RSpec.configure do |config|
8
- # add configuration here
10
+ config.use_transactional_fixtures = true
11
+ config.infer_spec_type_from_file_location!
12
+ config.filter_rails_from_backtrace!
13
+
14
+ config.after do
15
+ CountryStateSelect.reset_configuration!
16
+ end
9
17
  end
@@ -1,3 +1,8 @@
1
+ // DEPRECATED (jQuery + Chosen): kept for backward compatibility with apps
2
+ // already requiring this file. New apps should use the dependency-free
3
+ // build instead — see vendor/assets/javascript/country_state_select_vanilla.js
4
+ // (Sprockets) or the npm package / Stimulus controller (importmap, esbuild,
5
+ // webpack). This file will be removed in a future major version.
1
6
  function CountryStateSelect(options) {
2
7
 
3
8
  var state_id = options['state_id'];
@@ -10,6 +15,8 @@ function CountryStateSelect(options) {
10
15
  var city_name = $('#' + city_id).attr('name');
11
16
  var city_class = $('#' + city_id).attr('class');
12
17
 
18
+ var hasCityField = typeof city_name !== "undefined";
19
+
13
20
  return statesDropdown();
14
21
 
15
22
  // ====== ***** METHODS ***** ===================================================================== //
@@ -23,11 +30,18 @@ function CountryStateSelect(options) {
23
30
  return findStates($(this).val());
24
31
  });
25
32
 
33
+ // Bind the city lookup ONCE via event delegation. The state <select> is
34
+ // swapped out by replaceWith() on every country change, so a directly-bound
35
+ // handler would be lost (the old code re-bound it on every build, stacking
36
+ // duplicate listeners and firing multiple AJAX calls per change).
37
+ if (hasCityField) {
38
+ citiesDropdown();
39
+ }
26
40
  }
27
41
 
28
42
  function citiesDropdown() {
29
- $("#" + state_id).change(function () {
30
- return findCities($("#" + state_id).val(),$("#" + country_id).val());
43
+ $(document).on('change', '#' + state_id, function () {
44
+ return findCities($("#" + state_id).val(), $("#" + country_id).val());
31
45
  });
32
46
  }
33
47
 
@@ -74,6 +88,12 @@ function CountryStateSelect(options) {
74
88
  return options.hasOwnProperty("chosen_ui") && options['chosen_ui'];
75
89
  }
76
90
 
91
+ // Escape values before interpolating them into markup so names containing
92
+ // quotes, angle brackets or ampersands can't break the option or inject HTML.
93
+ function escapeHtml(value){
94
+ return $('<div>').text(value == null ? '' : value).html();
95
+ }
96
+
77
97
  function findStates(id) {
78
98
 
79
99
  //Remove all Chosen from existing fields
@@ -83,7 +103,7 @@ function CountryStateSelect(options) {
83
103
  //Perform AJAX request to get the data; on success, build the dropdown
84
104
  $.ajax({
85
105
  url: "/find_states",
86
- type: 'post',
106
+ type: 'get',
87
107
  dataType: 'json',
88
108
  cache: false,
89
109
  data: {country_id: id},
@@ -99,8 +119,7 @@ function CountryStateSelect(options) {
99
119
  //Perform AJAX request to get the data; on success, build the dropdown
100
120
  $.ajax({
101
121
  url: "/find_cities",
102
- type: 'post',
103
-
122
+ type: 'get',
104
123
  dataType: 'json',
105
124
  cache: false,
106
125
  data: {
@@ -113,33 +132,27 @@ function CountryStateSelect(options) {
113
132
 
114
133
  //Build the HTML for our dropdown menus
115
134
  function buildStatesDropdown(data) {
135
+ var html;
116
136
 
117
137
  if (data.length === 0) {
118
- html = '<input id="' + state_id + '" name="' + state_name + '" class="' + state_class + '" type="text" type="text" value="" >';
138
+ html = '<input id="' + state_id + '" name="' + state_name + '" class="' + state_class + '" type="text" value="" >';
119
139
  } else {
120
140
  html = '<select id="' + state_id + '" name="' + state_name + '" class="' + state_class + '" >';
121
- html += '<option>'+ find_select_option_text('state') +'</option>'
141
+ html += '<option>' + escapeHtml(find_select_option_text('state')) + '</option>';
122
142
 
123
- for (i = 0; i < data.length; i++) {
124
- html += '<option value='+data[i][0]+'>' + data[i][1] + '</option>';
143
+ for (var i = 0; i < data.length; i++) {
144
+ html += '<option value="' + escapeHtml(data[i][0]) + '">' + escapeHtml(data[i][1]) + '</option>';
125
145
  }
126
146
 
127
147
  html += '</select>';
128
148
  }
129
149
 
130
-
131
150
  $('#' + state_id).replaceWith(html);
132
151
 
133
152
  //This has to happen AFTER we've replaced the dropdown or text
134
153
  if (data.length > 0) {
135
154
  addChosenToState();
136
155
  }
137
-
138
- // [142] FIXME # Is there any other way to call city method , it is adding change method in every state change
139
- if(typeof city_name !== "undefined" ){
140
- citiesDropdown();
141
- };
142
-
143
156
  }
144
157
 
145
158
  function find_select_option_text(type){
@@ -152,14 +165,15 @@ function CountryStateSelect(options) {
152
165
  }
153
166
 
154
167
  function buildCitiesDropdown(data) {
168
+ var html;
155
169
 
156
170
  if (data.length === 0) {
157
- html = '<input id="' + city_id + '" name="' + city_name + '" class="' + city_class + '" type="text" type="text" value="" >';
171
+ html = '<input id="' + city_id + '" name="' + city_name + '" class="' + city_class + '" type="text" value="" >';
158
172
  } else {
159
173
  html = '<select id="' + city_id + '" name="' + city_name + '" class="' + city_class + '" >';
160
- html += '<option>'+ find_select_option_text('city') +'</option>'
161
- for (i = 0; i < data.length; i++) {
162
- html += '<option>' + data[i] + '</option>';
174
+ html += '<option>' + escapeHtml(find_select_option_text('city')) + '</option>';
175
+ for (var i = 0; i < data.length; i++) {
176
+ html += '<option>' + escapeHtml(data[i]) + '</option>';
163
177
  }
164
178
 
165
179
  html += '</select>';
@@ -174,4 +188,4 @@ function CountryStateSelect(options) {
174
188
 
175
189
  }
176
190
 
177
- }
191
+ }