country_state_select 4.0.0 → 4.0.1
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/dependabot.yml +14 -0
- data/.github/workflows/ci.yml +19 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +12 -0
- data/README.md +145 -11
- data/app/controllers/country_state_select/cscs_controller.rb +6 -2
- data/app/javascript/country_state_select/country_state_select.js +59 -25
- data/docs/json-api.md +4 -2
- data/gemfiles/rails_8.1.gemfile +7 -0
- data/lib/country_state_select/data_sources/base.rb +8 -0
- data/lib/country_state_select/data_sources/city_state.rb +4 -0
- data/lib/country_state_select/version.rb +1 -1
- data/package-lock.json +554 -0
- data/package.json +7 -1
- data/spec/requests/lookups_spec.rb +10 -0
- data/test/javascript/country_state_select.test.mjs +150 -0
- data/vendor/assets/javascript/country_state_select_vanilla.js +59 -25
- metadata +11 -3
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { test, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { JSDOM } from 'jsdom';
|
|
4
|
+
|
|
5
|
+
const dom = new JSDOM('<!doctype html><html><body></body></html>');
|
|
6
|
+
globalThis.window = dom.window;
|
|
7
|
+
globalThis.document = dom.window.document;
|
|
8
|
+
|
|
9
|
+
const { default: CountryStateSelect } = await import('../../app/javascript/country_state_select/country_state_select.js');
|
|
10
|
+
|
|
11
|
+
let requests;
|
|
12
|
+
let instances = [];
|
|
13
|
+
|
|
14
|
+
function stubFetch() {
|
|
15
|
+
requests = [];
|
|
16
|
+
globalThis.fetch = (url) => new Promise((resolve) => {
|
|
17
|
+
requests.push({
|
|
18
|
+
url,
|
|
19
|
+
respond: (data) => resolve({ ok: true, json: () => Promise.resolve(data) })
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function requestFor(path, param) {
|
|
25
|
+
const found = requests.find((r) => r.url.startsWith(`${path}?`) && r.url.includes(param));
|
|
26
|
+
assert.ok(found, `expected a request to ${path} with ${param}`);
|
|
27
|
+
return found;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function settle() {
|
|
31
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function changeCountry(value) {
|
|
35
|
+
const country = document.getElementById('country');
|
|
36
|
+
country.value = value;
|
|
37
|
+
country.dispatchEvent(new window.Event('change', { bubbles: true }));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function setup(options = {}) {
|
|
41
|
+
const instance = CountryStateSelect({ country_id: 'country', state_id: 'state', city_id: 'city', announce: false, ...options });
|
|
42
|
+
instances.push(instance);
|
|
43
|
+
return instance;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
document.body.innerHTML = `
|
|
48
|
+
<select id="country" name="address[country]">
|
|
49
|
+
<option value=""></option><option value="US">United States</option><option value="IN">India</option>
|
|
50
|
+
</select>
|
|
51
|
+
<select id="state" name="address[state]" class="form-select" required aria-describedby="state-help"
|
|
52
|
+
data-country-state-select-target="state"></select>
|
|
53
|
+
<select id="city" name="address[city]" data-country-state-select-target="city"></select>`;
|
|
54
|
+
stubFetch();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
instances.forEach((instance) => instance.destroy());
|
|
59
|
+
instances = [];
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('keeps the state field attributes when rebuilding it', async () => {
|
|
63
|
+
setup();
|
|
64
|
+
|
|
65
|
+
changeCountry('US');
|
|
66
|
+
requestFor('/find_states', 'country_id=US').respond([['CA', 'California']]);
|
|
67
|
+
await settle();
|
|
68
|
+
|
|
69
|
+
const state = document.getElementById('state');
|
|
70
|
+
assert.equal(state.tagName, 'SELECT');
|
|
71
|
+
assert.equal(state.name, 'address[state]');
|
|
72
|
+
assert.equal(state.className, 'form-select');
|
|
73
|
+
assert.equal(state.required, true);
|
|
74
|
+
assert.equal(state.getAttribute('aria-describedby'), 'state-help');
|
|
75
|
+
assert.equal(state.getAttribute('data-country-state-select-target'), 'state');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('keeps the attributes when a field becomes a text input', async () => {
|
|
79
|
+
setup();
|
|
80
|
+
|
|
81
|
+
changeCountry('US');
|
|
82
|
+
requestFor('/find_states', 'country_id=US').respond([]);
|
|
83
|
+
await settle();
|
|
84
|
+
|
|
85
|
+
const state = document.getElementById('state');
|
|
86
|
+
assert.equal(state.tagName, 'INPUT');
|
|
87
|
+
assert.equal(state.type, 'text');
|
|
88
|
+
assert.equal(state.required, true);
|
|
89
|
+
assert.equal(state.getAttribute('data-country-state-select-target'), 'state');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('ignores a states response that arrives after a newer country change', async () => {
|
|
93
|
+
setup();
|
|
94
|
+
|
|
95
|
+
changeCountry('US');
|
|
96
|
+
changeCountry('IN');
|
|
97
|
+
requestFor('/find_states', 'country_id=IN').respond([['MP', 'Madhya Pradesh']]);
|
|
98
|
+
await settle();
|
|
99
|
+
requestFor('/find_states', 'country_id=US').respond([['CA', 'California']]);
|
|
100
|
+
await settle();
|
|
101
|
+
|
|
102
|
+
const values = Array.from(document.getElementById('state').options).map((o) => o.value);
|
|
103
|
+
assert.deepEqual(values, ['', 'MP']);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('ignores a cities response for a state that is no longer selected', async () => {
|
|
107
|
+
setup();
|
|
108
|
+
changeCountry('US');
|
|
109
|
+
requestFor('/find_states', 'country_id=US').respond([['CA', 'California'], ['NY', 'New York']]);
|
|
110
|
+
await settle();
|
|
111
|
+
|
|
112
|
+
const state = document.getElementById('state');
|
|
113
|
+
state.value = 'CA';
|
|
114
|
+
state.dispatchEvent(new window.Event('change', { bubbles: true }));
|
|
115
|
+
state.value = 'NY';
|
|
116
|
+
state.dispatchEvent(new window.Event('change', { bubbles: true }));
|
|
117
|
+
requestFor('/find_cities', 'state_id=NY').respond(['New York City']);
|
|
118
|
+
await settle();
|
|
119
|
+
requestFor('/find_cities', 'state_id=CA').respond(['Los Angeles']);
|
|
120
|
+
await settle();
|
|
121
|
+
|
|
122
|
+
const cities = Array.from(document.getElementById('city').options).map((o) => o.value);
|
|
123
|
+
assert.deepEqual(cities, ['', 'New York City']);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('changing the country does not request cities', async () => {
|
|
127
|
+
setup();
|
|
128
|
+
|
|
129
|
+
changeCountry('US');
|
|
130
|
+
requestFor('/find_states', 'country_id=US').respond([['CA', 'California']]);
|
|
131
|
+
await settle();
|
|
132
|
+
|
|
133
|
+
assert.deepEqual(requests.map((r) => r.url.split('?')[0]), ['/find_states']);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('destroy removes the country listener', () => {
|
|
137
|
+
setup().destroy();
|
|
138
|
+
|
|
139
|
+
changeCountry('US');
|
|
140
|
+
|
|
141
|
+
assert.equal(requests.length, 0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('refresh does not add a second country listener', () => {
|
|
145
|
+
setup().refresh();
|
|
146
|
+
|
|
147
|
+
changeCountry('US');
|
|
148
|
+
|
|
149
|
+
assert.equal(requests.length, 1);
|
|
150
|
+
});
|
|
@@ -28,10 +28,14 @@
|
|
|
28
28
|
var liveRegion = announce ? ensureLiveRegion() : null;
|
|
29
29
|
|
|
30
30
|
var enhancedInstances = {};
|
|
31
|
+
var boundCountryEl = null;
|
|
32
|
+
var statesRequestId = 0;
|
|
33
|
+
var citiesRequestId = 0;
|
|
34
|
+
var NON_TRANSFERABLE_ATTRIBUTES = ['type', 'value'];
|
|
31
35
|
|
|
32
36
|
init();
|
|
33
37
|
|
|
34
|
-
return { refresh:
|
|
38
|
+
return { refresh: refresh, destroy: destroy };
|
|
35
39
|
|
|
36
40
|
// ====== ***** INITIALIZATION ***** ===================================== //
|
|
37
41
|
|
|
@@ -41,9 +45,8 @@
|
|
|
41
45
|
var countryEl = document.getElementById(countryId);
|
|
42
46
|
if (!countryEl) return;
|
|
43
47
|
|
|
44
|
-
countryEl.addEventListener('change',
|
|
45
|
-
|
|
46
|
-
});
|
|
48
|
+
countryEl.addEventListener('change', countryChange);
|
|
49
|
+
boundCountryEl = countryEl;
|
|
47
50
|
|
|
48
51
|
if (hasCityField) {
|
|
49
52
|
document.addEventListener('change', delegatedStateChange);
|
|
@@ -83,10 +86,26 @@
|
|
|
83
86
|
return !!el && el.tagName === 'SELECT' && el.options.length > 1;
|
|
84
87
|
}
|
|
85
88
|
|
|
89
|
+
function refresh() {
|
|
90
|
+
removeListeners();
|
|
91
|
+
init();
|
|
92
|
+
}
|
|
93
|
+
|
|
86
94
|
function destroy() {
|
|
95
|
+
removeListeners();
|
|
96
|
+
Object.keys(enhancedInstances).forEach(destroyEnhancement);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function removeListeners() {
|
|
100
|
+
if (boundCountryEl) boundCountryEl.removeEventListener('change', countryChange);
|
|
101
|
+
boundCountryEl = null;
|
|
87
102
|
document.removeEventListener('change', delegatedStateChange);
|
|
88
103
|
}
|
|
89
104
|
|
|
105
|
+
function countryChange(event) {
|
|
106
|
+
findStates(event.target.value, { userInitiated: true });
|
|
107
|
+
}
|
|
108
|
+
|
|
90
109
|
function delegatedStateChange(event) {
|
|
91
110
|
if (event.target && event.target.id === stateId) {
|
|
92
111
|
findCities(event.target.value, document.getElementById(countryId).value, { userInitiated: true });
|
|
@@ -95,8 +114,13 @@
|
|
|
95
114
|
|
|
96
115
|
// ====== ***** DATA LOOKUP ***** ========================================= //
|
|
97
116
|
|
|
117
|
+
// Each lookup takes a fresh request id; a response is only applied if no
|
|
118
|
+
// newer lookup for the same field started while it was in flight, so a
|
|
119
|
+
// slow reply for a previously selected country/state can't overwrite the
|
|
120
|
+
// current one.
|
|
98
121
|
function findStates(countryCode, ctx) {
|
|
99
122
|
ctx = ctx || {};
|
|
123
|
+
var requestId = ++statesRequestId;
|
|
100
124
|
if (ctx.userInitiated) findCities('', '', { silent: true });
|
|
101
125
|
|
|
102
126
|
if (offlineData && offlineData.states) {
|
|
@@ -105,6 +129,7 @@
|
|
|
105
129
|
}
|
|
106
130
|
|
|
107
131
|
fetchJson(statesUrl, { country_id: countryCode }).then(function (data) {
|
|
132
|
+
if (requestId !== statesRequestId) return;
|
|
108
133
|
buildStatesDropdown(data || [], ctx);
|
|
109
134
|
});
|
|
110
135
|
}
|
|
@@ -112,6 +137,12 @@
|
|
|
112
137
|
function findCities(stateCode, countryCode, ctx) {
|
|
113
138
|
ctx = ctx || {};
|
|
114
139
|
if (!hasCityField) return;
|
|
140
|
+
var requestId = ++citiesRequestId;
|
|
141
|
+
|
|
142
|
+
if (!stateCode) {
|
|
143
|
+
buildCitiesDropdown([], ctx);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
115
146
|
|
|
116
147
|
if (offlineData && offlineData.cities) {
|
|
117
148
|
var key = countryCode + ':' + stateCode;
|
|
@@ -120,6 +151,7 @@
|
|
|
120
151
|
}
|
|
121
152
|
|
|
122
153
|
fetchJson(citiesUrl, { country_id: countryCode, state_id: stateCode }).then(function (data) {
|
|
154
|
+
if (requestId !== citiesRequestId) return;
|
|
123
155
|
buildCitiesDropdown(data || [], ctx);
|
|
124
156
|
});
|
|
125
157
|
}
|
|
@@ -146,18 +178,15 @@
|
|
|
146
178
|
rebuildField(stateId, data, ctx.preselect, options.state_placeholder);
|
|
147
179
|
if (!ctx.silent) announceUpdate('State options updated');
|
|
148
180
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
silent: true
|
|
159
|
-
});
|
|
160
|
-
}
|
|
181
|
+
// A user-initiated country change already cleared the city field in
|
|
182
|
+
// findStates, so only the initial (pre-selected) load cascades here.
|
|
183
|
+
if (hasCityField && !ctx.userInitiated && ctx.preselect !== undefined) {
|
|
184
|
+
var stateEl = document.getElementById(stateId);
|
|
185
|
+
var preselectedCity = options.selected_city || (stateEl && stateEl.getAttribute('data-selected-city'));
|
|
186
|
+
findCities(stateEl ? stateEl.value : '', countryId && document.getElementById(countryId).value, {
|
|
187
|
+
preselect: preselectedCity,
|
|
188
|
+
silent: true
|
|
189
|
+
});
|
|
161
190
|
}
|
|
162
191
|
}
|
|
163
192
|
|
|
@@ -167,9 +196,12 @@
|
|
|
167
196
|
}
|
|
168
197
|
|
|
169
198
|
// Rebuilds `fieldId` as a <select> (data present) or a plain text <input>
|
|
170
|
-
// (no data for the parent selection)
|
|
171
|
-
//
|
|
172
|
-
//
|
|
199
|
+
// (no data for the parent selection). The new element replaces the old
|
|
200
|
+
// one, so every attribute is carried over (id, name, class, required,
|
|
201
|
+
// aria-*, Stimulus target data attributes, ...) — otherwise validation,
|
|
202
|
+
// accessibility wiring and Stimulus targets are lost after the first
|
|
203
|
+
// rebuild. Tom Select instances are torn down first and re-created on
|
|
204
|
+
// the new node.
|
|
173
205
|
function rebuildField(fieldId, pairs, preselectValue, placeholder) {
|
|
174
206
|
var el = document.getElementById(fieldId);
|
|
175
207
|
if (!el) return;
|
|
@@ -178,18 +210,14 @@
|
|
|
178
210
|
|
|
179
211
|
if (!pairs || pairs.length === 0) {
|
|
180
212
|
var input = document.createElement('input');
|
|
213
|
+
copyAttributes(el, input);
|
|
181
214
|
input.type = 'text';
|
|
182
|
-
input.id = fieldId;
|
|
183
|
-
input.name = el.name;
|
|
184
|
-
input.className = el.className;
|
|
185
215
|
el.replaceWith(input);
|
|
186
216
|
return;
|
|
187
217
|
}
|
|
188
218
|
|
|
189
219
|
var select = document.createElement('select');
|
|
190
|
-
select
|
|
191
|
-
select.name = el.name;
|
|
192
|
-
select.className = el.className;
|
|
220
|
+
copyAttributes(el, select);
|
|
193
221
|
|
|
194
222
|
var blank = document.createElement('option');
|
|
195
223
|
blank.textContent = placeholder || '';
|
|
@@ -210,6 +238,12 @@
|
|
|
210
238
|
enhance(fieldId);
|
|
211
239
|
}
|
|
212
240
|
|
|
241
|
+
function copyAttributes(from, to) {
|
|
242
|
+
Array.prototype.forEach.call(from.attributes, function (attr) {
|
|
243
|
+
if (NON_TRANSFERABLE_ATTRIBUTES.indexOf(attr.name) === -1) to.setAttribute(attr.name, attr.value);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
213
247
|
function enhance(fieldId) {
|
|
214
248
|
if (!fieldId || typeof enhancer !== 'function') return;
|
|
215
249
|
var el = document.getElementById(fieldId);
|
metadata
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: country_state_select
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 4.0.
|
|
4
|
+
version: 4.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Arvind Vyas
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: bin
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
11
|
+
date: 2026-11-11 00:00:00.000000000 Z
|
|
11
12
|
dependencies:
|
|
12
13
|
- !ruby/object:Gem::Dependency
|
|
13
14
|
name: bundler
|
|
@@ -185,6 +186,7 @@ executables: []
|
|
|
185
186
|
extensions: []
|
|
186
187
|
extra_rdoc_files: []
|
|
187
188
|
files:
|
|
189
|
+
- ".github/dependabot.yml"
|
|
188
190
|
- ".github/workflows/ci.yml"
|
|
189
191
|
- ".gitignore"
|
|
190
192
|
- ".rspec"
|
|
@@ -207,6 +209,7 @@ files:
|
|
|
207
209
|
- gemfiles/rails_7.1.gemfile
|
|
208
210
|
- gemfiles/rails_7.2.gemfile
|
|
209
211
|
- gemfiles/rails_8.0.gemfile
|
|
212
|
+
- gemfiles/rails_8.1.gemfile
|
|
210
213
|
- lib/country_state_select.rb
|
|
211
214
|
- lib/country_state_select/configuration.rb
|
|
212
215
|
- lib/country_state_select/country_metadata.rb
|
|
@@ -220,6 +223,7 @@ files:
|
|
|
220
223
|
- lib/generators/country_state_select/install/install_generator.rb
|
|
221
224
|
- lib/generators/country_state_select/install/templates/README
|
|
222
225
|
- lib/generators/country_state_select/install/templates/initializer.rb
|
|
226
|
+
- package-lock.json
|
|
223
227
|
- package.json
|
|
224
228
|
- spec/country_state_select_spec.rb
|
|
225
229
|
- spec/lib/country_state_select/configuration_spec.rb
|
|
@@ -229,6 +233,7 @@ files:
|
|
|
229
233
|
- spec/requests/business_form_spec.rb
|
|
230
234
|
- spec/requests/lookups_spec.rb
|
|
231
235
|
- spec/spec_helper.rb
|
|
236
|
+
- test/javascript/country_state_select.test.mjs
|
|
232
237
|
- vendor/assets/javascript/country_state_select.js.erb
|
|
233
238
|
- vendor/assets/javascript/country_state_select_vanilla.js
|
|
234
239
|
homepage: https://github.com/arvindvyas/Country-State-Select
|
|
@@ -236,6 +241,7 @@ licenses:
|
|
|
236
241
|
- MIT
|
|
237
242
|
metadata:
|
|
238
243
|
changelog_uri: https://github.com/arvindvyas/Country-State-Select/blob/master/CHANGELOG.md
|
|
244
|
+
post_install_message:
|
|
239
245
|
rdoc_options: []
|
|
240
246
|
require_paths:
|
|
241
247
|
- lib
|
|
@@ -250,7 +256,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
250
256
|
- !ruby/object:Gem::Version
|
|
251
257
|
version: '0'
|
|
252
258
|
requirements: []
|
|
253
|
-
rubygems_version:
|
|
259
|
+
rubygems_version: 3.5.22
|
|
260
|
+
signing_key:
|
|
254
261
|
specification_version: 4
|
|
255
262
|
summary: Dynamically select Country and State.
|
|
256
263
|
test_files:
|
|
@@ -262,3 +269,4 @@ test_files:
|
|
|
262
269
|
- spec/requests/business_form_spec.rb
|
|
263
270
|
- spec/requests/lookups_spec.rb
|
|
264
271
|
- spec/spec_helper.rb
|
|
272
|
+
- test/javascript/country_state_select.test.mjs
|