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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +41 -0
- data/.gitignore +2 -0
- data/.rspec +1 -0
- data/CHANGELOG.md +56 -0
- data/README.md +196 -111
- data/Rakefile +26 -0
- data/app/controllers/country_state_select/cscs_controller.rb +31 -8
- data/app/javascript/country_state_select/controller.js +66 -0
- data/app/javascript/country_state_select/country_state_select.js +245 -0
- data/config/importmap.rb +4 -0
- data/config/routes.rb +14 -4
- data/country_state_select.gemspec +14 -4
- data/docs/cities.md +35 -0
- data/docs/images/cascading-select-demo.png +0 -0
- data/docs/json-api.md +41 -0
- data/docs/migration-v4.md +32 -0
- data/gemfiles/rails_7.0.gemfile +10 -0
- data/gemfiles/rails_7.1.gemfile +7 -0
- data/gemfiles/rails_7.2.gemfile +7 -0
- data/gemfiles/rails_8.0.gemfile +7 -0
- data/lib/country_state_select/configuration.rb +89 -0
- data/lib/country_state_select/country_metadata.rb +29 -0
- data/lib/country_state_select/data/dial_codes.yml +250 -0
- data/lib/country_state_select/data_sources/base.rb +27 -0
- data/lib/country_state_select/data_sources/city_state.rb +28 -0
- data/lib/country_state_select/engine.rb +31 -0
- data/lib/country_state_select/form_builder.rb +48 -0
- data/lib/country_state_select/localization.rb +41 -0
- data/lib/country_state_select/version.rb +1 -1
- data/lib/country_state_select.rb +83 -23
- data/lib/generators/country_state_select/install/install_generator.rb +22 -0
- data/lib/generators/country_state_select/install/templates/README +34 -0
- data/lib/generators/country_state_select/install/templates/initializer.rb +29 -0
- data/package.json +40 -0
- data/spec/country_state_select_spec.rb +2 -2
- data/spec/lib/country_state_select/configuration_spec.rb +63 -0
- data/spec/lib/country_state_select/country_metadata_spec.rb +42 -0
- data/spec/lib/country_state_select/filtering_spec.rb +68 -0
- data/spec/lib/country_state_select/localization_spec.rb +30 -0
- data/spec/requests/business_form_spec.rb +38 -0
- data/spec/requests/lookups_spec.rb +72 -0
- data/spec/spec_helper.rb +12 -4
- data/vendor/assets/javascript/country_state_select.js.erb +35 -21
- data/vendor/assets/javascript/country_state_select_vanilla.js +252 -0
- metadata +147 -17
- data/.circleci/config.yml +0 -15
- data/.travis.yml +0 -14
- data/lib/country_state_select/engine3.rb +0 -10
- data/lib/country_state_select/railtie.rb +0 -10
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Dependency-free core: no jQuery required. Works as an ES module (npm,
|
|
2
|
+
// importmap-rails, esbuild/webpack) and is also compiled by `rake js:build`
|
|
3
|
+
// into a plain-script build for Sprockets users — see
|
|
4
|
+
// vendor/assets/javascript/country_state_select_vanilla.js. Keep this file
|
|
5
|
+
// as the single source of truth; edit the compiled build only by re-running
|
|
6
|
+
// `rake js:build`.
|
|
7
|
+
//
|
|
8
|
+
// The legacy jQuery + Chosen implementation
|
|
9
|
+
// (vendor/assets/javascript/country_state_select.js.erb) is kept for
|
|
10
|
+
// backward compatibility but is deprecated — new apps should use this file,
|
|
11
|
+
// optionally through the bundled Stimulus controller (controller.js).
|
|
12
|
+
|
|
13
|
+
export default function CountryStateSelect(options) {
|
|
14
|
+
var countryId = options.country_id;
|
|
15
|
+
var stateId = options.state_id;
|
|
16
|
+
var cityId = options.city_id;
|
|
17
|
+
var hasCityField = !!cityId;
|
|
18
|
+
var announce = options.announce !== false;
|
|
19
|
+
var offlineData = options.offline_data || null;
|
|
20
|
+
var statesUrl = options.states_url || '/find_states';
|
|
21
|
+
var citiesUrl = options.cities_url || '/find_cities';
|
|
22
|
+
var enhancer = options.enhance; // optional fn(selectEl, tomSelectOptions) e.g. Tom Select hookup
|
|
23
|
+
var enhancerOptions = options.enhance_options || {};
|
|
24
|
+
var liveRegion = announce ? ensureLiveRegion() : null;
|
|
25
|
+
|
|
26
|
+
var enhancedInstances = {};
|
|
27
|
+
|
|
28
|
+
init();
|
|
29
|
+
|
|
30
|
+
return { refresh: init, destroy: destroy };
|
|
31
|
+
|
|
32
|
+
// ====== ***** INITIALIZATION ***** ===================================== //
|
|
33
|
+
|
|
34
|
+
function init() {
|
|
35
|
+
enhance(countryId);
|
|
36
|
+
|
|
37
|
+
var countryEl = document.getElementById(countryId);
|
|
38
|
+
if (!countryEl) return;
|
|
39
|
+
|
|
40
|
+
countryEl.addEventListener('change', function () {
|
|
41
|
+
findStates(countryEl.value, { userInitiated: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (hasCityField) {
|
|
45
|
+
document.addEventListener('change', delegatedStateChange);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Edit-form support: a server-rendered form (e.g. via the FormBuilder
|
|
49
|
+
// helpers, which read the model's current country/state to pick the
|
|
50
|
+
// right <select> options) already shows the correct state/city on
|
|
51
|
+
// load — trust it and only enhance it, rather than discarding it for
|
|
52
|
+
// a fresh, unselected fetch. Only fetch here for fields that truly
|
|
53
|
+
// need it: a plain/manual integration where the target is still a
|
|
54
|
+
// blank shell (a text input, or a <select> with no real options),
|
|
55
|
+
// even though the country already has a value. Pre-selection for that
|
|
56
|
+
// fetch comes from an explicit option or a `data-selected-value`/
|
|
57
|
+
// `data-selected-city` attribute.
|
|
58
|
+
var countryValue = countryEl.value;
|
|
59
|
+
var stateEl = document.getElementById(stateId);
|
|
60
|
+
var cityEl = hasCityField ? document.getElementById(cityId) : null;
|
|
61
|
+
|
|
62
|
+
if (isPopulated(stateEl)) {
|
|
63
|
+
enhance(stateId);
|
|
64
|
+
} else if (countryValue) {
|
|
65
|
+
var preselectedState = options.selected_state || (stateEl && stateEl.getAttribute('data-selected-value'));
|
|
66
|
+
findStates(countryValue, { preselect: preselectedState, silent: true });
|
|
67
|
+
return; // findStates cascades into the city fetch itself once it resolves
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (isPopulated(cityEl)) {
|
|
71
|
+
enhance(cityId);
|
|
72
|
+
} else if (hasCityField && stateEl && stateEl.value) {
|
|
73
|
+
var preselectedCity = options.selected_city || (cityEl && cityEl.getAttribute('data-selected-city'));
|
|
74
|
+
findCities(stateEl.value, countryValue, { preselect: preselectedCity, silent: true });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isPopulated(el) {
|
|
79
|
+
return !!el && el.tagName === 'SELECT' && el.options.length > 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function destroy() {
|
|
83
|
+
document.removeEventListener('change', delegatedStateChange);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function delegatedStateChange(event) {
|
|
87
|
+
if (event.target && event.target.id === stateId) {
|
|
88
|
+
findCities(event.target.value, document.getElementById(countryId).value, { userInitiated: true });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ====== ***** DATA LOOKUP ***** ========================================= //
|
|
93
|
+
|
|
94
|
+
function findStates(countryCode, ctx) {
|
|
95
|
+
ctx = ctx || {};
|
|
96
|
+
if (ctx.userInitiated) findCities('', '', { silent: true });
|
|
97
|
+
|
|
98
|
+
if (offlineData && offlineData.states) {
|
|
99
|
+
buildStatesDropdown(offlineData.states[countryCode] || [], ctx);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
fetchJson(statesUrl, { country_id: countryCode }).then(function (data) {
|
|
104
|
+
buildStatesDropdown(data || [], ctx);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function findCities(stateCode, countryCode, ctx) {
|
|
109
|
+
ctx = ctx || {};
|
|
110
|
+
if (!hasCityField) return;
|
|
111
|
+
|
|
112
|
+
if (offlineData && offlineData.cities) {
|
|
113
|
+
var key = countryCode + ':' + stateCode;
|
|
114
|
+
buildCitiesDropdown(offlineData.cities[key] || [], ctx);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
fetchJson(citiesUrl, { country_id: countryCode, state_id: stateCode }).then(function (data) {
|
|
119
|
+
buildCitiesDropdown(data || [], ctx);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function fetchJson(url, params) {
|
|
124
|
+
var query = Object.keys(params)
|
|
125
|
+
.map(function (k) { return encodeURIComponent(k) + '=' + encodeURIComponent(params[k] == null ? '' : params[k]); })
|
|
126
|
+
.join('&');
|
|
127
|
+
|
|
128
|
+
return fetch(url + '?' + query, {
|
|
129
|
+
headers: { Accept: 'application/json' },
|
|
130
|
+
credentials: 'same-origin'
|
|
131
|
+
}).then(function (response) {
|
|
132
|
+
if (!response.ok) return [];
|
|
133
|
+
return response.json();
|
|
134
|
+
}).catch(function () {
|
|
135
|
+
return [];
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ====== ***** DOM BUILDING ***** ======================================== //
|
|
140
|
+
|
|
141
|
+
function buildStatesDropdown(data, ctx) {
|
|
142
|
+
rebuildField(stateId, data, ctx.preselect, options.state_placeholder);
|
|
143
|
+
if (!ctx.silent) announceUpdate('State options updated');
|
|
144
|
+
|
|
145
|
+
var countryHasChanged = ctx.userInitiated;
|
|
146
|
+
if (hasCityField) {
|
|
147
|
+
if (countryHasChanged) {
|
|
148
|
+
findCities('', '', { silent: true });
|
|
149
|
+
} else if (ctx.preselect !== undefined) {
|
|
150
|
+
var stateEl = document.getElementById(stateId);
|
|
151
|
+
var preselectedCity = options.selected_city || (stateEl && stateEl.getAttribute('data-selected-city'));
|
|
152
|
+
findCities(stateEl ? stateEl.value : '', countryId && document.getElementById(countryId).value, {
|
|
153
|
+
preselect: preselectedCity,
|
|
154
|
+
silent: true
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function buildCitiesDropdown(data, ctx) {
|
|
161
|
+
rebuildField(cityId, data.map(function (name) { return [name, name]; }), ctx.preselect, options.city_placeholder);
|
|
162
|
+
if (!ctx.silent) announceUpdate('City options updated');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Rebuilds `fieldId` as a <select> (data present) or a plain text <input>
|
|
166
|
+
// (no data for the parent selection) while preserving its id/name/class —
|
|
167
|
+
// mutating the existing node in place rather than replaceWith() keeps
|
|
168
|
+
// external references (Tom Select instances, other listeners) valid.
|
|
169
|
+
function rebuildField(fieldId, pairs, preselectValue, placeholder) {
|
|
170
|
+
var el = document.getElementById(fieldId);
|
|
171
|
+
if (!el) return;
|
|
172
|
+
|
|
173
|
+
destroyEnhancement(fieldId);
|
|
174
|
+
|
|
175
|
+
if (!pairs || pairs.length === 0) {
|
|
176
|
+
var input = document.createElement('input');
|
|
177
|
+
input.type = 'text';
|
|
178
|
+
input.id = fieldId;
|
|
179
|
+
input.name = el.name;
|
|
180
|
+
input.className = el.className;
|
|
181
|
+
el.replaceWith(input);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
var select = document.createElement('select');
|
|
186
|
+
select.id = fieldId;
|
|
187
|
+
select.name = el.name;
|
|
188
|
+
select.className = el.className;
|
|
189
|
+
|
|
190
|
+
var blank = document.createElement('option');
|
|
191
|
+
blank.textContent = placeholder || '';
|
|
192
|
+
blank.value = '';
|
|
193
|
+
select.appendChild(blank);
|
|
194
|
+
|
|
195
|
+
pairs.forEach(function (pair) {
|
|
196
|
+
var opt = document.createElement('option');
|
|
197
|
+
opt.value = pair[0];
|
|
198
|
+
opt.textContent = pair[1];
|
|
199
|
+
if (preselectValue != null && String(pair[0]) === String(preselectValue)) {
|
|
200
|
+
opt.selected = true;
|
|
201
|
+
}
|
|
202
|
+
select.appendChild(opt);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
el.replaceWith(select);
|
|
206
|
+
enhance(fieldId);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function enhance(fieldId) {
|
|
210
|
+
if (!fieldId || typeof enhancer !== 'function') return;
|
|
211
|
+
var el = document.getElementById(fieldId);
|
|
212
|
+
if (!el || el.tagName !== 'SELECT') return;
|
|
213
|
+
|
|
214
|
+
enhancedInstances[fieldId] = enhancer(el, enhancerOptions);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function destroyEnhancement(fieldId) {
|
|
218
|
+
var instance = enhancedInstances[fieldId];
|
|
219
|
+
if (instance && typeof instance.destroy === 'function') instance.destroy();
|
|
220
|
+
delete enhancedInstances[fieldId];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ====== ***** ACCESSIBILITY ***** ======================================= //
|
|
224
|
+
|
|
225
|
+
function ensureLiveRegion() {
|
|
226
|
+
var existing = document.getElementById('country-state-select-live-region');
|
|
227
|
+
if (existing) return existing;
|
|
228
|
+
|
|
229
|
+
var region = document.createElement('div');
|
|
230
|
+
region.id = 'country-state-select-live-region';
|
|
231
|
+
region.setAttribute('aria-live', 'polite');
|
|
232
|
+
region.setAttribute('role', 'status');
|
|
233
|
+
region.style.position = 'absolute';
|
|
234
|
+
region.style.width = '1px';
|
|
235
|
+
region.style.height = '1px';
|
|
236
|
+
region.style.overflow = 'hidden';
|
|
237
|
+
region.style.clip = 'rect(0 0 0 0)';
|
|
238
|
+
document.body.appendChild(region);
|
|
239
|
+
return region;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function announceUpdate(message) {
|
|
243
|
+
if (liveRegion) liveRegion.textContent = message;
|
|
244
|
+
}
|
|
245
|
+
}
|
data/config/importmap.rb
ADDED
data/config/routes.rb
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
Rails
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
# Rails loads (evals) every engine's config/routes.rb as a plain file each
|
|
4
|
+
# time routes are drawn, expecting it to call `.draw` itself — that's why
|
|
5
|
+
# this isn't wrapped in the usual `Engine.routes.draw` block. Guarding on
|
|
6
|
+
# `draw_routes` here (rather than only documenting an opt-out) is what lets
|
|
7
|
+
# a host app disable the top-level /find_states, /find_cities routes and
|
|
8
|
+
# mount the engine at a custom path instead — see README "Custom route path".
|
|
9
|
+
if CountryStateSelect.configuration.draw_routes
|
|
10
|
+
Rails.application.routes.draw do
|
|
11
|
+
scope module: 'country_state_select' do
|
|
12
|
+
# GET is the correct verb for these read-only lookups (and sidesteps CSRF).
|
|
13
|
+
# POST is kept so existing host apps that wired the old routes keep working.
|
|
14
|
+
match 'find_states' => 'cscs#find_states', via: %i[get post]
|
|
15
|
+
match 'find_cities' => 'cscs#find_cities', via: %i[get post]
|
|
16
|
+
end
|
|
7
17
|
end
|
|
8
18
|
end
|
|
@@ -13,16 +13,26 @@ Gem::Specification.new do |spec|
|
|
|
13
13
|
spec.description = 'Country State Select is a Ruby Gem that aims to make Country and State/Province selection a cinch in Ruby on Rails environments.'
|
|
14
14
|
spec.homepage = 'https://github.com/arvindvyas/Country-State-Select'
|
|
15
15
|
spec.license = 'MIT'
|
|
16
|
+
spec.required_ruby_version = '>= 3.1'
|
|
16
17
|
|
|
17
|
-
spec.
|
|
18
|
+
spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/master/CHANGELOG.md"
|
|
19
|
+
|
|
20
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.start_with?('spec/dummy/') }
|
|
18
21
|
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
|
19
22
|
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
|
20
23
|
spec.require_paths = ['lib']
|
|
21
24
|
|
|
22
25
|
spec.add_development_dependency 'bundler'
|
|
23
|
-
spec.add_development_dependency '
|
|
24
|
-
spec.add_development_dependency '
|
|
26
|
+
spec.add_development_dependency 'bundler-audit'
|
|
27
|
+
spec.add_development_dependency 'capybara'
|
|
28
|
+
spec.add_development_dependency 'importmap-rails'
|
|
29
|
+
spec.add_development_dependency 'propshaft'
|
|
30
|
+
spec.add_development_dependency 'puma'
|
|
31
|
+
spec.add_development_dependency 'rake', '~> 13.0'
|
|
32
|
+
spec.add_development_dependency 'rspec-rails'
|
|
33
|
+
spec.add_development_dependency 'sqlite3'
|
|
34
|
+
spec.add_development_dependency 'stimulus-rails'
|
|
25
35
|
|
|
26
36
|
spec.add_runtime_dependency 'city-state'
|
|
27
|
-
spec.add_runtime_dependency 'rails'
|
|
37
|
+
spec.add_runtime_dependency 'rails', '>= 7.0'
|
|
28
38
|
end
|
data/docs/cities.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# City support
|
|
2
|
+
|
|
3
|
+
City data comes from the same [city-state](https://github.com/loureirorg/city-state) gem as countries and states. Coverage varies a lot by country — it's strong for the US and reasonably populated for many others, but it is not exhaustive. If a state has no city data, the field falls back to a plain text input, the same way a country with no states falls back for the state field.
|
|
4
|
+
|
|
5
|
+
Coverage gaps are a known limitation of the underlying data, not something this gem can fix directly — but you're not stuck with it: see [Custom data sources](../README.md#custom-data-sources) in the README to swap in your own city data (a database table, a paid geodata API, etc.) without changing any of your views or JavaScript.
|
|
6
|
+
|
|
7
|
+
## form_with
|
|
8
|
+
|
|
9
|
+
```erb
|
|
10
|
+
<div data-controller="country-state-select">
|
|
11
|
+
<%= f.country_select :country %>
|
|
12
|
+
<%= f.state_select :state, :country %>
|
|
13
|
+
<%= f.city_select :city, :state, :country %>
|
|
14
|
+
</div>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`city_select` takes `(method, state_method, country_method, options = {}, html_options = {})` — it reads the model's current state and country to decide whether to render a `<select>` (data available) or a text `<input>` (no data for that state/country pair).
|
|
18
|
+
|
|
19
|
+
## simple_form
|
|
20
|
+
|
|
21
|
+
```erb
|
|
22
|
+
<% options = { form: f, field_names: { state: :state_field, country: :country_field } } %>
|
|
23
|
+
<%= f.input :city_field, CountryStateSelect.city_options(options) %>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Direct API
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
CountryStateSelect.collect_cities('CA', 'US')
|
|
30
|
+
# => ["Los Angeles", "San Francisco", ...]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## JavaScript
|
|
34
|
+
|
|
35
|
+
Any of the JS integrations (Stimulus, vanilla/Sprockets, or the legacy jQuery build) pick up a city field automatically when you pass `city_id` (or, for the Stimulus controller, add a `city` target) alongside `country_id`/`state_id`. See the README's [JavaScript setup](../README.md#javascript-setup) section.
|
|
Binary file
|
data/docs/json-api.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# JSON lookup API
|
|
2
|
+
|
|
3
|
+
The engine exposes two read-only, cacheable JSON endpoints. They back the bundled JavaScript, but are documented here so a SPA, mobile app, or hand-rolled integration can call them directly.
|
|
4
|
+
|
|
5
|
+
## `GET /find_states`
|
|
6
|
+
|
|
7
|
+
**Params:** `country_id` — an ISO 3166-1 alpha-2 country code (e.g. `US`).
|
|
8
|
+
|
|
9
|
+
**Response:** an array of `[code, name]` pairs, or `[]` if the country has no states.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
GET /find_states?country_id=US
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
[["AK", "Alaska"], ["AL", "Alabama"], ["AR", "Arkansas"], ...]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Honors whatever `only_countries`/`except_countries`/`priority_countries` and custom `data_source` are configured — it does not bypass your configuration the way calling the underlying data gem directly would.
|
|
20
|
+
|
|
21
|
+
## `GET /find_cities`
|
|
22
|
+
|
|
23
|
+
**Params:** `state_id`, `country_id`.
|
|
24
|
+
|
|
25
|
+
**Response:** an array of plain city name strings, or `[]` if `state_id` is blank or the pair has no data.
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
GET /find_cities?state_id=CA&country_id=US
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
["Los Angeles", "San Francisco", "San Diego", ...]
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Caching
|
|
36
|
+
|
|
37
|
+
Both endpoints send `Cache-Control` and `ETag` headers based on `CountryStateSelect.configuration.cache_duration` (default: 1 day). A conditional `GET` with a matching `If-None-Match` gets a `304 Not Modified` with no body. Set `cache_duration = nil` to disable both.
|
|
38
|
+
|
|
39
|
+
## Routes
|
|
40
|
+
|
|
41
|
+
These are mounted at `/find_states` and `/find_cities` by default. See the README's [Custom route path](../README.md#custom-route-path) section to change that.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Migrating from 3.x to 4.0
|
|
2
|
+
|
|
3
|
+
## Requirements changed
|
|
4
|
+
|
|
5
|
+
- **Rails >= 7.0** is now required (Propshaft/importmap-rails, the default modern asset pipeline this release targets, itself requires Rails 7+). Rails 3–6 support (`engine3.rb`, `railtie.rb`) has been removed. If you're on an older Rails, stay on the `country_state_select` 3.x series.
|
|
6
|
+
- **Ruby >= 3.1** is now required.
|
|
7
|
+
|
|
8
|
+
## Nothing breaks if you do nothing
|
|
9
|
+
|
|
10
|
+
The Ruby API (`countries_collection`, `collect_states`, `state_options`, `city_options`, etc.) and the legacy jQuery/Chosen JavaScript (`vendor/assets/javascript/country_state_select.js.erb`) are unchanged and still work exactly as in 3.x, aside from the fixes below. You can upgrade the gem, bump your Rails version if needed, and keep everything else as-is.
|
|
11
|
+
|
|
12
|
+
## Behavior fixes worth knowing about
|
|
13
|
+
|
|
14
|
+
- **`cities_collection` (the simple_form helper) now actually filters by country.** In 3.x it silently ignored the `:country` key in `field_names` and only ever looked up cities by state, which meant it never respected the country half of a state/country pair. Duplicate state codes across countries would have picked up the wrong country's cities. If you were relying on that behavior, it's now consistent with `collect_cities(state, country)`, i.e. it needs `field_names` to include both `:state` and `:country`.
|
|
15
|
+
- **`/find_states` and `/find_cities` are HTTP-cached by default** (`Cache-Control` + `ETag`, 1 day). This is new — 3.x sent no caching headers at all. Set `CountryStateSelect.configure { |c| c.cache_duration = nil }` to opt out.
|
|
16
|
+
- **`/find_states` and `/find_cities` now go through your configured data source and `only`/`except`/`priority` filters**, not the `city-state` gem directly. This only changes behavior if you set those new options.
|
|
17
|
+
|
|
18
|
+
## New, opt-in features
|
|
19
|
+
|
|
20
|
+
Everything in the README's Configuration section — `priority_countries`, `only`/`except` filtering, `flags`, `dial_codes`, `localize_names`, a pluggable `data_source`, `draw_routes` — is new in 4.0 and defaults to off/unchanged, so adopting the gem doesn't require adopting any of it.
|
|
21
|
+
|
|
22
|
+
## If you want to modernize your JavaScript
|
|
23
|
+
|
|
24
|
+
You don't have to — the legacy jQuery/Chosen build keeps working — but if you want to drop the jQuery dependency:
|
|
25
|
+
|
|
26
|
+
1. Remove `//= require country_state_select` and `//= require chosen-jquery` (and the corresponding CSS `require`s) from your asset manifest.
|
|
27
|
+
2. Follow the README's [JavaScript setup](../README.md#javascript-setup) for whichever pipeline you use (importmap+Stimulus is the default for Rails 7/8 apps).
|
|
28
|
+
3. If you were using `chosen_ui: true`, the modern equivalent is [Tom Select](https://tom-select.js.org/) — Chosen is unmaintained. The Stimulus controller has a `tom-select` value for this; the vanilla/npm build takes an `enhance` callback for the same purpose.
|
|
29
|
+
|
|
30
|
+
## If you want to switch from simple_form to `form_with`
|
|
31
|
+
|
|
32
|
+
Also optional. `f.country_select`, `f.state_select`, `f.city_select` are new `form_with`/`form_for` builder methods that don't require simple_form — see the README's [Using it in a form](../README.md#using-it-in-a-form) section. Your existing simple_form-based views keep working unchanged.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
source 'https://rubygems.org'
|
|
4
|
+
|
|
5
|
+
gem 'rails', '~> 7.0.0'
|
|
6
|
+
# Rails 7.0/7.1's sqlite3 adapter only supports the 1.4.x gem; the gemspec's
|
|
7
|
+
# unpinned `sqlite3` dependency otherwise resolves to the latest 2.x series.
|
|
8
|
+
gem 'sqlite3', '~> 1.4'
|
|
9
|
+
|
|
10
|
+
gemspec path: '..'
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CountryStateSelect
|
|
4
|
+
# Global, per-app configuration. Set it from an initializer:
|
|
5
|
+
#
|
|
6
|
+
# CountryStateSelect.configure do |config|
|
|
7
|
+
# config.priority_countries = %w[US IN GB]
|
|
8
|
+
# config.flags = true
|
|
9
|
+
# config.cache_duration = 1.week
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# Every option here is a *default* — the equivalent per-call keyword
|
|
13
|
+
# (e.g. `countries_collection(priority: ...)`) always wins.
|
|
14
|
+
class Configuration
|
|
15
|
+
# Data source answering countries/states/cities lookups. Accepts an
|
|
16
|
+
# object implementing DataSources::Base or a symbol (:city_state).
|
|
17
|
+
attr_accessor :data_source
|
|
18
|
+
|
|
19
|
+
# ISO codes listed first in the country dropdown (issue #66).
|
|
20
|
+
attr_accessor :priority_countries
|
|
21
|
+
|
|
22
|
+
# Whitelist / blacklist of ISO codes applied to every country lookup.
|
|
23
|
+
attr_accessor :only_countries, :except_countries
|
|
24
|
+
|
|
25
|
+
# Prepend the emoji flag to country labels ("🇮🇳 India").
|
|
26
|
+
attr_accessor :flags
|
|
27
|
+
|
|
28
|
+
# Append the international dial code to country labels ("India (+91)").
|
|
29
|
+
attr_accessor :dial_codes
|
|
30
|
+
|
|
31
|
+
# Translate country names through I18n / the `countries` gem when
|
|
32
|
+
# translations are available. Labels fall back to the English name.
|
|
33
|
+
attr_accessor :localize_names
|
|
34
|
+
|
|
35
|
+
# How long the JSON lookup endpoints are HTTP-cached (they serve
|
|
36
|
+
# static data). Set to nil/false to disable cache headers.
|
|
37
|
+
attr_accessor :cache_duration
|
|
38
|
+
|
|
39
|
+
# Draw the legacy top-level `/find_states` + `/find_cities` routes in
|
|
40
|
+
# the host app. Disable if you mount the engine at a custom path:
|
|
41
|
+
#
|
|
42
|
+
# config.draw_routes = false
|
|
43
|
+
# # config/routes.rb: mount CountryStateSelect::Rails::Engine => '/csc'
|
|
44
|
+
attr_accessor :draw_routes
|
|
45
|
+
|
|
46
|
+
def initialize
|
|
47
|
+
@data_source = :city_state
|
|
48
|
+
@priority_countries = []
|
|
49
|
+
@only_countries = nil
|
|
50
|
+
@except_countries = nil
|
|
51
|
+
@flags = false
|
|
52
|
+
@dial_codes = false
|
|
53
|
+
@localize_names = false
|
|
54
|
+
@cache_duration = 86_400 # 1 day, in seconds
|
|
55
|
+
@draw_routes = true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def data_source_instance
|
|
59
|
+
@data_source_instance ||=
|
|
60
|
+
case data_source
|
|
61
|
+
when :city_state, nil then DataSources::CityState.new
|
|
62
|
+
when Symbol then raise ArgumentError, "Unknown data source: #{data_source.inspect}"
|
|
63
|
+
else data_source
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Invalidate the memoized adapter when the source changes at runtime
|
|
68
|
+
# (mostly useful in tests).
|
|
69
|
+
def data_source=(source)
|
|
70
|
+
@data_source_instance = nil
|
|
71
|
+
@data_source = source
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class << self
|
|
76
|
+
def configuration
|
|
77
|
+
@configuration ||= Configuration.new
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def configure
|
|
81
|
+
yield(configuration)
|
|
82
|
+
configuration
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def reset_configuration!
|
|
86
|
+
@configuration = Configuration.new
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CountryStateSelect
|
|
4
|
+
# Optional label decoration: flag emoji and/or international dial code.
|
|
5
|
+
# Both read from a small bundled table so the gem stays dependency-free;
|
|
6
|
+
# a country missing from the table (rare, mostly disputed territories)
|
|
7
|
+
# simply renders without the decoration instead of raising.
|
|
8
|
+
module CountryMetadata
|
|
9
|
+
DIAL_CODES = YAML.safe_load_file(
|
|
10
|
+
File.expand_path('data/dial_codes.yml', __dir__)
|
|
11
|
+
).freeze
|
|
12
|
+
|
|
13
|
+
def self.decorate(code, name)
|
|
14
|
+
label = name
|
|
15
|
+
label = "#{flag(code)} #{label}" if CountryStateSelect.configuration.flags && flag(code)
|
|
16
|
+
label = "#{label} (+#{DIAL_CODES[code.to_s]})" if CountryStateSelect.configuration.dial_codes && DIAL_CODES[code.to_s]
|
|
17
|
+
label
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Converts a two-letter ISO code to its flag emoji via the Unicode
|
|
21
|
+
# "Regional Indicator Symbol" trick (each letter A-Z maps to U+1F1E6..).
|
|
22
|
+
def self.flag(code)
|
|
23
|
+
chars = code.to_s.upcase.chars
|
|
24
|
+
return nil unless chars.length == 2 && chars.all? { |c| c.match?(/[A-Z]/) }
|
|
25
|
+
|
|
26
|
+
chars.map { |c| [0x1F1E6 + (c.ord - 'A'.ord)].pack('U') }.join
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|