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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b44110d0208f2adeaa74cd4300fdae9edc51dffc5d7f4523cf23a720fd79c352
|
|
4
|
+
data.tar.gz: ed1bb261ce072b64e3b03434ae6157fbaa01fc830501285cbb5ed8d4f4b71a74
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '0378bdd52c565fa664d7df27ef41e339392d4a94262db5b5bd8f074b80c10412af1f2ab546880f540f9cb27937bebcdc415275ead5f8d1113a4e538a15ff30b5'
|
|
7
|
+
data.tar.gz: 7c0fda7c020be2912f2337e52761610ac58886bdeecb61cd396afa175f2036e9c12913bdd6203798be7fbb471e6d63e33abdb8f22f3df53a5f4df798669ae9df
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
updates:
|
|
3
|
+
- package-ecosystem: github-actions
|
|
4
|
+
directory: /
|
|
5
|
+
schedule:
|
|
6
|
+
interval: monthly
|
|
7
|
+
- package-ecosystem: bundler
|
|
8
|
+
directory: /
|
|
9
|
+
schedule:
|
|
10
|
+
interval: monthly
|
|
11
|
+
- package-ecosystem: npm
|
|
12
|
+
directory: /
|
|
13
|
+
schedule:
|
|
14
|
+
interval: monthly
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -5,6 +5,9 @@ on:
|
|
|
5
5
|
branches: [master]
|
|
6
6
|
pull_request:
|
|
7
7
|
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
8
11
|
jobs:
|
|
9
12
|
ruby-versions:
|
|
10
13
|
name: Ruby ${{ matrix.ruby }} (latest Rails)
|
|
@@ -12,9 +15,9 @@ jobs:
|
|
|
12
15
|
strategy:
|
|
13
16
|
fail-fast: false
|
|
14
17
|
matrix:
|
|
15
|
-
ruby: ['3.1', '3.2', '3.3', '3.4']
|
|
18
|
+
ruby: ['3.1', '3.2', '3.3', '3.4', '4.0']
|
|
16
19
|
steps:
|
|
17
|
-
- uses: actions/checkout@
|
|
20
|
+
- uses: actions/checkout@v5
|
|
18
21
|
- uses: ruby/setup-ruby@v1
|
|
19
22
|
with:
|
|
20
23
|
ruby-version: ${{ matrix.ruby }}
|
|
@@ -28,14 +31,26 @@ jobs:
|
|
|
28
31
|
strategy:
|
|
29
32
|
fail-fast: false
|
|
30
33
|
matrix:
|
|
31
|
-
rails: ['7.0', '7.1', '7.2', '8.0']
|
|
34
|
+
rails: ['7.0', '7.1', '7.2', '8.0', '8.1']
|
|
32
35
|
env:
|
|
33
36
|
BUNDLE_GEMFILE: gemfiles/rails_${{ matrix.rails }}.gemfile
|
|
34
37
|
steps:
|
|
35
|
-
- uses: actions/checkout@
|
|
38
|
+
- uses: actions/checkout@v5
|
|
36
39
|
- uses: ruby/setup-ruby@v1
|
|
37
40
|
with:
|
|
38
41
|
ruby-version: '3.3'
|
|
39
42
|
bundler-cache: true
|
|
40
43
|
- name: Run tests
|
|
41
44
|
run: bundle exec rspec
|
|
45
|
+
|
|
46
|
+
javascript:
|
|
47
|
+
name: JavaScript
|
|
48
|
+
runs-on: ubuntu-latest
|
|
49
|
+
steps:
|
|
50
|
+
- uses: actions/checkout@v5
|
|
51
|
+
- uses: actions/setup-node@v5
|
|
52
|
+
with:
|
|
53
|
+
node-version: '22'
|
|
54
|
+
cache: npm
|
|
55
|
+
- run: npm ci
|
|
56
|
+
- run: npm test
|
data/.gitignore
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.0.1
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- Rebuilt state/city fields now keep all of the original element's attributes (`required`, `aria-*`, `disabled`, Stimulus `data-*-target`, ...). Previously only `id`, `name` and `class` survived the first country/state change, which dropped validation and accessibility wiring and broke Stimulus reconnects ("Missing target element").
|
|
7
|
+
- A slow `/find_states` or `/find_cities` response for a previous selection can no longer overwrite the dropdown for the current one.
|
|
8
|
+
- `destroy()` now removes the country `change` listener, and `refresh()` no longer stacks a second set of listeners (which caused duplicate lookups per change).
|
|
9
|
+
- Changing the country no longer sends two `/find_cities` requests for an empty state; the city field is cleared client-side.
|
|
10
|
+
- The lookup endpoints' ETag now includes the gem version and the data source's `cache_key`, so browsers re-fetch after an upgrade or a data change instead of receiving a stale `304`. Custom data sources can override `cache_key`.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- CI: Ruby 4.0 and Rails 8.1 added to the matrix, a JavaScript test job (`npm test`, Node test runner + jsdom), least-privilege workflow `permissions`, and Dependabot for Actions/bundler/npm.
|
|
14
|
+
|
|
3
15
|
## 4.0.0
|
|
4
16
|
|
|
5
17
|
Modernization release. See [docs/migration-v4.md](docs/migration-v4.md) for the full upgrade guide — existing 3.x code (Ruby API and the legacy jQuery JS) keeps working unchanged.
|
data/README.md
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
# Country State Select
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/country_state_select)
|
|
2
4
|
[](https://github.com/arvindvyas/Country-State-Select/actions/workflows/ci.yml)
|
|
3
|
-
[](https://rubygems.org/gems/country_state_select)
|
|
6
|
+
[](LICENSE.txt)
|
|
4
7
|
|
|
5
8
|
Country State Select gives you cascading Country → State/Province → City dropdowns for Rails forms: pick a country, the state field repopulates; pick a state, the city field repopulates.
|
|
6
9
|
|
|
7
10
|

|
|
8
11
|
|
|
9
|
-
Country
|
|
12
|
+
Country, state and city data comes from the [city-state](https://github.com/loureirorg/city-state) gem (pluggable — see [Custom data sources](#custom-data-sources)).
|
|
10
13
|
|
|
11
14
|
**v4.0 is a modernization release.** Highlights:
|
|
12
15
|
|
|
@@ -18,7 +21,20 @@ Country/state/state data comes from the [city-state](https://github.com/loureiro
|
|
|
18
21
|
- HTTP-cached JSON lookup endpoints
|
|
19
22
|
- An install generator (`rails g country_state_select:install`)
|
|
20
23
|
|
|
21
|
-
Upgrading from 3.x? See [docs/migration-v4.md](docs/migration-v4.md) — the legacy jQuery/Chosen JavaScript still works, but Rails < 7.0 is no longer supported (stay on the 3.x gem for older Rails).
|
|
24
|
+
See the [CHANGELOG](CHANGELOG.md) for what changed in each release. Upgrading from 3.x? See [docs/migration-v4.md](docs/migration-v4.md) — the legacy jQuery/Chosen JavaScript still works, but Rails < 7.0 is no longer supported (stay on the 3.x gem for older Rails).
|
|
25
|
+
|
|
26
|
+
## Table of contents
|
|
27
|
+
|
|
28
|
+
- [Installation](#installation)
|
|
29
|
+
- [Supported versions](#supported-versions)
|
|
30
|
+
- [JavaScript setup](#javascript-setup)
|
|
31
|
+
- [JavaScript API](#javascript-api)
|
|
32
|
+
- [Using it in a form](#using-it-in-a-form)
|
|
33
|
+
- [Configuration](#configuration)
|
|
34
|
+
- [JSON API](#json-api)
|
|
35
|
+
- [Development](#development)
|
|
36
|
+
- [Contributing](#contributing)
|
|
37
|
+
- [License](#license)
|
|
22
38
|
|
|
23
39
|
## Installation
|
|
24
40
|
|
|
@@ -31,7 +47,14 @@ bundle install
|
|
|
31
47
|
rails g country_state_select:install # optional: initializer + setup instructions
|
|
32
48
|
```
|
|
33
49
|
|
|
34
|
-
|
|
50
|
+
## Supported versions
|
|
51
|
+
|
|
52
|
+
| Gem version | Ruby | Rails |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| 4.x | 3.1 – 4.0 | 7.0 – 8.1 |
|
|
55
|
+
| 3.x | no minimum declared | 3.0 – 7.x (use 3.x for Rails < 7.0) |
|
|
56
|
+
|
|
57
|
+
CI tests every Ruby and Rails version listed for 4.x. Rails 7.0 and 7.1 and Ruby 3.1 and 3.2 are past end of life upstream; they still pass CI but may be dropped in a future major version.
|
|
35
58
|
|
|
36
59
|
## JavaScript setup
|
|
37
60
|
|
|
@@ -85,14 +108,22 @@ Stimulus controller values (all optional, set as `data-country-state-select-<val
|
|
|
85
108
|
|
|
86
109
|
```js
|
|
87
110
|
document.addEventListener('DOMContentLoaded', function () {
|
|
88
|
-
CountryStateSelect({
|
|
111
|
+
CountryStateSelect({
|
|
112
|
+
country_id: "country_field_id",
|
|
113
|
+
state_id: "state_field_id",
|
|
114
|
+
city_id: "city_field_id" // optional
|
|
115
|
+
})
|
|
89
116
|
})
|
|
90
117
|
```
|
|
91
118
|
|
|
119
|
+
All options are listed in [JavaScript API](#javascript-api). If you use Turbo without Stimulus, see [Turbo without Stimulus](#turbo-without-stimulus).
|
|
120
|
+
|
|
92
121
|
### npm / esbuild / webpack
|
|
93
122
|
|
|
123
|
+
The package is not on the npm registry yet. Install it from GitHub:
|
|
124
|
+
|
|
94
125
|
```
|
|
95
|
-
npm install
|
|
126
|
+
npm install github:arvindvyas/Country-State-Select @hotwired/stimulus
|
|
96
127
|
```
|
|
97
128
|
|
|
98
129
|
```js
|
|
@@ -123,6 +154,69 @@ $(document).on('turbo:load', function () {
|
|
|
123
154
|
})
|
|
124
155
|
```
|
|
125
156
|
|
|
157
|
+
## JavaScript API
|
|
158
|
+
|
|
159
|
+
The Stimulus controller is a thin wrapper around one function. Call it directly when you aren't using Stimulus:
|
|
160
|
+
|
|
161
|
+
```js
|
|
162
|
+
const csc = CountryStateSelect({ country_id: "country", state_id: "state", city_id: "city" })
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
| Option | Default | Purpose |
|
|
166
|
+
|---|---|---|
|
|
167
|
+
| `country_id` | required | `id` of the country `<select>` |
|
|
168
|
+
| `state_id` | required | `id` of the state field |
|
|
169
|
+
| `city_id` | none | `id` of the city field; leave it out for a country → state form |
|
|
170
|
+
| `states_url` / `cities_url` | `/find_states` / `/find_cities` | Lookup endpoints, if you mounted them elsewhere |
|
|
171
|
+
| `state_placeholder` / `city_placeholder` | `""` | Text of the blank first option |
|
|
172
|
+
| `selected_state` / `selected_city` | none | Value to pre-select when the field is filled on page load. You can also set `data-selected-value` on the state field or `data-selected-city` on the state or city field |
|
|
173
|
+
| `announce` | `true` | Announce updates through an `aria-live` region |
|
|
174
|
+
| `enhance` | none | `function (selectEl, options)` called on every rebuilt `<select>`, e.g. to attach Tom Select. Return an object with `destroy()` so it can be torn down before the next rebuild |
|
|
175
|
+
| `enhance_options` | `{}` | Passed as the second argument to `enhance` |
|
|
176
|
+
| `offline_data` | none | Serve lookups from memory instead of the endpoints (see below) |
|
|
177
|
+
|
|
178
|
+
It returns `{ refresh, destroy }`:
|
|
179
|
+
|
|
180
|
+
- `refresh()` re-reads the fields and re-attaches listeners, e.g. after you replace the form's HTML. It can be called repeatedly; listeners are not duplicated.
|
|
181
|
+
- `destroy()` removes all listeners and tears down any `enhance` instances.
|
|
182
|
+
|
|
183
|
+
**How fields are updated.** When the country changes, the state field is rebuilt with the new options and the city field is cleared. If there is nothing to list, the field becomes a text input so the user can type a value. The rebuilt field keeps every attribute of the original (`id`, `name`, `class`, `required`, `aria-*`, `data-*`, and so on), so validation, accessibility labels and Stimulus targets survive. If the user changes the country or state again before a lookup returns, the older response is ignored.
|
|
184
|
+
|
|
185
|
+
**Tom Select without Stimulus:**
|
|
186
|
+
|
|
187
|
+
```js
|
|
188
|
+
CountryStateSelect({
|
|
189
|
+
country_id: "country",
|
|
190
|
+
state_id: "state",
|
|
191
|
+
enhance: (el, opts) => new TomSelect(el, opts),
|
|
192
|
+
enhance_options: { create: false }
|
|
193
|
+
})
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**Offline data.** Skip the network by passing the lookup data up front. States are keyed by country code, cities by `"COUNTRY:STATE"`. A missing `cities` key falls back to the endpoint.
|
|
197
|
+
|
|
198
|
+
```js
|
|
199
|
+
CountryStateSelect({
|
|
200
|
+
country_id: "country", state_id: "state", city_id: "city",
|
|
201
|
+
offline_data: {
|
|
202
|
+
states: { US: [["CA", "California"], ["NY", "New York"]] },
|
|
203
|
+
cities: { "US:CA": ["Los Angeles", "San Francisco"] }
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Turbo without Stimulus
|
|
209
|
+
|
|
210
|
+
The Stimulus controller cleans up after itself. With the plain function, destroy the instance before Turbo caches the page, or each visit leaves another listener on `document`:
|
|
211
|
+
|
|
212
|
+
```js
|
|
213
|
+
let csc
|
|
214
|
+
document.addEventListener("turbo:load", () => {
|
|
215
|
+
csc = CountryStateSelect({ country_id: "country", state_id: "state", city_id: "city" })
|
|
216
|
+
})
|
|
217
|
+
document.addEventListener("turbo:before-cache", () => csc?.destroy())
|
|
218
|
+
```
|
|
219
|
+
|
|
126
220
|
## Using it in a form
|
|
127
221
|
|
|
128
222
|
### `form_with` (no simple_form required)
|
|
@@ -174,9 +268,11 @@ end
|
|
|
174
268
|
|
|
175
269
|
Every option here can also be passed per-call: `CountryStateSelect.countries_collection(priority: %w[US])`, `f.country_select :country, only: %w[US CA]`.
|
|
176
270
|
|
|
271
|
+
The `priority_countries`, `only_countries` and `except_countries` settings shape the **country** list. They are not applied by the `/find_states` and `/find_cities` endpoints.
|
|
272
|
+
|
|
177
273
|
### Custom data sources
|
|
178
274
|
|
|
179
|
-
Swap out `city-state` (e.g. for a database table, or the `countries` gem) by implementing the adapter interface:
|
|
275
|
+
Swap out `city-state` (e.g. for a database table, or the `countries` gem) by implementing the adapter interface. The lookup endpoints, form helpers and `collect_*` methods all go through it, so no view or JavaScript changes are needed:
|
|
180
276
|
|
|
181
277
|
```ruby
|
|
182
278
|
class MyDataSource < CountryStateSelect::DataSources::Base
|
|
@@ -191,11 +287,19 @@ class MyDataSource < CountryStateSelect::DataSources::Base
|
|
|
191
287
|
def cities(state_code, country_code)
|
|
192
288
|
['Los Angeles', 'San Francisco'] # plain names
|
|
193
289
|
end
|
|
290
|
+
|
|
291
|
+
# Optional: part of the lookup endpoints' ETag. Return something that
|
|
292
|
+
# changes with your data so browsers don't keep serving a stale 304.
|
|
293
|
+
def cache_key
|
|
294
|
+
"my-data-#{Location.maximum(:updated_at).to_i}"
|
|
295
|
+
end
|
|
194
296
|
end
|
|
195
297
|
|
|
196
298
|
CountryStateSelect.configure { |c| c.data_source = MyDataSource.new }
|
|
197
299
|
```
|
|
198
300
|
|
|
301
|
+
`cache_key` defaults to the class name. The lookup responses are HTTP-cached (1 day by default), and their `ETag` is built from the gem version, this key and the request params. If your data can change without a deploy, return a key that changes with it; otherwise browsers that already hold a response keep getting `304 Not Modified` until it expires.
|
|
302
|
+
|
|
199
303
|
### Custom route path
|
|
200
304
|
|
|
201
305
|
By default the engine draws `/find_states` and `/find_cities` at the top level. To mount it elsewhere:
|
|
@@ -221,20 +325,50 @@ The lookup endpoints are documented in [docs/json-api.md](docs/json-api.md) —
|
|
|
221
325
|
|
|
222
326
|
## Development
|
|
223
327
|
|
|
328
|
+
Ruby specs:
|
|
329
|
+
|
|
224
330
|
```
|
|
225
331
|
bundle install
|
|
226
332
|
bundle exec rspec
|
|
333
|
+
|
|
334
|
+
# against a specific Rails version
|
|
335
|
+
BUNDLE_GEMFILE=gemfiles/rails_7.2.gemfile bundle install
|
|
336
|
+
BUNDLE_GEMFILE=gemfiles/rails_7.2.gemfile bundle exec rspec
|
|
227
337
|
```
|
|
228
338
|
|
|
229
|
-
|
|
339
|
+
JavaScript tests (Node's built-in test runner with jsdom, in `test/javascript/`):
|
|
340
|
+
|
|
341
|
+
```
|
|
342
|
+
npm ci
|
|
343
|
+
npm test
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
The JavaScript source of truth is `app/javascript/country_state_select/country_state_select.js`. After editing it, regenerate the Sprockets build and commit both files:
|
|
347
|
+
|
|
348
|
+
```
|
|
349
|
+
bundle exec rake js:build
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
The dummy Rails app used for the request specs lives in `spec/dummy` and doubles as a runnable demo:
|
|
230
353
|
|
|
231
354
|
```
|
|
232
355
|
cd spec/dummy
|
|
233
356
|
bin/rails server
|
|
234
357
|
```
|
|
235
358
|
|
|
236
|
-
CI runs the suite
|
|
359
|
+
CI runs the Ruby suite on Ruby 3.1–4.0 (latest Rails) and on Rails 7.0, 7.1, 7.2, 8.0 and 8.1 (see `gemfiles/`), plus the JavaScript tests.
|
|
237
360
|
|
|
238
361
|
## Contributing
|
|
239
362
|
|
|
240
|
-
Fork
|
|
363
|
+
1. Fork the repo and create a branch.
|
|
364
|
+
2. Make your change with a test: an RSpec example for Ruby, a `test/javascript/` test for the JS.
|
|
365
|
+
3. If you changed the JS source, run `bundle exec rake js:build`.
|
|
366
|
+
4. Run `bundle exec rspec` and `npm test`.
|
|
367
|
+
5. Add a line under the next version in `CHANGELOG.md`.
|
|
368
|
+
6. Open a pull request.
|
|
369
|
+
|
|
370
|
+
Bug reports and questions are welcome in [GitHub issues](https://github.com/arvindvyas/Country-State-Select/issues).
|
|
371
|
+
|
|
372
|
+
## License
|
|
373
|
+
|
|
374
|
+
Released under the [MIT License](LICENSE.txt).
|
|
@@ -30,12 +30,16 @@ module CountryStateSelect
|
|
|
30
30
|
# both at the HTTP layer (Cache-Control) and via conditional GET
|
|
31
31
|
# (ETag/304), avoiding a render entirely on repeat lookups. Disabled by
|
|
32
32
|
# setting `CountryStateSelect.configuration.cache_duration = nil`.
|
|
33
|
+
# The ETag includes the gem version and the data source's cache_key so
|
|
34
|
+
# an upgrade or a data change invalidates responses browsers already hold.
|
|
33
35
|
def render_cached(data, cache_key)
|
|
34
|
-
|
|
36
|
+
configuration = CountryStateSelect.configuration
|
|
37
|
+
duration = configuration.cache_duration
|
|
35
38
|
|
|
36
39
|
if duration
|
|
37
40
|
expires_in duration, public: true
|
|
38
|
-
|
|
41
|
+
etag = [CountryStateSelect::VERSION, configuration.data_source_instance.cache_key, cache_key]
|
|
42
|
+
return unless stale?(etag: etag)
|
|
39
43
|
end
|
|
40
44
|
|
|
41
45
|
respond_to do |format|
|
|
@@ -24,10 +24,14 @@ export default function CountryStateSelect(options) {
|
|
|
24
24
|
var liveRegion = announce ? ensureLiveRegion() : null;
|
|
25
25
|
|
|
26
26
|
var enhancedInstances = {};
|
|
27
|
+
var boundCountryEl = null;
|
|
28
|
+
var statesRequestId = 0;
|
|
29
|
+
var citiesRequestId = 0;
|
|
30
|
+
var NON_TRANSFERABLE_ATTRIBUTES = ['type', 'value'];
|
|
27
31
|
|
|
28
32
|
init();
|
|
29
33
|
|
|
30
|
-
return { refresh:
|
|
34
|
+
return { refresh: refresh, destroy: destroy };
|
|
31
35
|
|
|
32
36
|
// ====== ***** INITIALIZATION ***** ===================================== //
|
|
33
37
|
|
|
@@ -37,9 +41,8 @@ export default function CountryStateSelect(options) {
|
|
|
37
41
|
var countryEl = document.getElementById(countryId);
|
|
38
42
|
if (!countryEl) return;
|
|
39
43
|
|
|
40
|
-
countryEl.addEventListener('change',
|
|
41
|
-
|
|
42
|
-
});
|
|
44
|
+
countryEl.addEventListener('change', countryChange);
|
|
45
|
+
boundCountryEl = countryEl;
|
|
43
46
|
|
|
44
47
|
if (hasCityField) {
|
|
45
48
|
document.addEventListener('change', delegatedStateChange);
|
|
@@ -79,10 +82,26 @@ export default function CountryStateSelect(options) {
|
|
|
79
82
|
return !!el && el.tagName === 'SELECT' && el.options.length > 1;
|
|
80
83
|
}
|
|
81
84
|
|
|
85
|
+
function refresh() {
|
|
86
|
+
removeListeners();
|
|
87
|
+
init();
|
|
88
|
+
}
|
|
89
|
+
|
|
82
90
|
function destroy() {
|
|
91
|
+
removeListeners();
|
|
92
|
+
Object.keys(enhancedInstances).forEach(destroyEnhancement);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function removeListeners() {
|
|
96
|
+
if (boundCountryEl) boundCountryEl.removeEventListener('change', countryChange);
|
|
97
|
+
boundCountryEl = null;
|
|
83
98
|
document.removeEventListener('change', delegatedStateChange);
|
|
84
99
|
}
|
|
85
100
|
|
|
101
|
+
function countryChange(event) {
|
|
102
|
+
findStates(event.target.value, { userInitiated: true });
|
|
103
|
+
}
|
|
104
|
+
|
|
86
105
|
function delegatedStateChange(event) {
|
|
87
106
|
if (event.target && event.target.id === stateId) {
|
|
88
107
|
findCities(event.target.value, document.getElementById(countryId).value, { userInitiated: true });
|
|
@@ -91,8 +110,13 @@ export default function CountryStateSelect(options) {
|
|
|
91
110
|
|
|
92
111
|
// ====== ***** DATA LOOKUP ***** ========================================= //
|
|
93
112
|
|
|
113
|
+
// Each lookup takes a fresh request id; a response is only applied if no
|
|
114
|
+
// newer lookup for the same field started while it was in flight, so a
|
|
115
|
+
// slow reply for a previously selected country/state can't overwrite the
|
|
116
|
+
// current one.
|
|
94
117
|
function findStates(countryCode, ctx) {
|
|
95
118
|
ctx = ctx || {};
|
|
119
|
+
var requestId = ++statesRequestId;
|
|
96
120
|
if (ctx.userInitiated) findCities('', '', { silent: true });
|
|
97
121
|
|
|
98
122
|
if (offlineData && offlineData.states) {
|
|
@@ -101,6 +125,7 @@ export default function CountryStateSelect(options) {
|
|
|
101
125
|
}
|
|
102
126
|
|
|
103
127
|
fetchJson(statesUrl, { country_id: countryCode }).then(function (data) {
|
|
128
|
+
if (requestId !== statesRequestId) return;
|
|
104
129
|
buildStatesDropdown(data || [], ctx);
|
|
105
130
|
});
|
|
106
131
|
}
|
|
@@ -108,6 +133,12 @@ export default function CountryStateSelect(options) {
|
|
|
108
133
|
function findCities(stateCode, countryCode, ctx) {
|
|
109
134
|
ctx = ctx || {};
|
|
110
135
|
if (!hasCityField) return;
|
|
136
|
+
var requestId = ++citiesRequestId;
|
|
137
|
+
|
|
138
|
+
if (!stateCode) {
|
|
139
|
+
buildCitiesDropdown([], ctx);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
111
142
|
|
|
112
143
|
if (offlineData && offlineData.cities) {
|
|
113
144
|
var key = countryCode + ':' + stateCode;
|
|
@@ -116,6 +147,7 @@ export default function CountryStateSelect(options) {
|
|
|
116
147
|
}
|
|
117
148
|
|
|
118
149
|
fetchJson(citiesUrl, { country_id: countryCode, state_id: stateCode }).then(function (data) {
|
|
150
|
+
if (requestId !== citiesRequestId) return;
|
|
119
151
|
buildCitiesDropdown(data || [], ctx);
|
|
120
152
|
});
|
|
121
153
|
}
|
|
@@ -142,18 +174,15 @@ export default function CountryStateSelect(options) {
|
|
|
142
174
|
rebuildField(stateId, data, ctx.preselect, options.state_placeholder);
|
|
143
175
|
if (!ctx.silent) announceUpdate('State options updated');
|
|
144
176
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
silent: true
|
|
155
|
-
});
|
|
156
|
-
}
|
|
177
|
+
// A user-initiated country change already cleared the city field in
|
|
178
|
+
// findStates, so only the initial (pre-selected) load cascades here.
|
|
179
|
+
if (hasCityField && !ctx.userInitiated && ctx.preselect !== undefined) {
|
|
180
|
+
var stateEl = document.getElementById(stateId);
|
|
181
|
+
var preselectedCity = options.selected_city || (stateEl && stateEl.getAttribute('data-selected-city'));
|
|
182
|
+
findCities(stateEl ? stateEl.value : '', countryId && document.getElementById(countryId).value, {
|
|
183
|
+
preselect: preselectedCity,
|
|
184
|
+
silent: true
|
|
185
|
+
});
|
|
157
186
|
}
|
|
158
187
|
}
|
|
159
188
|
|
|
@@ -163,9 +192,12 @@ export default function CountryStateSelect(options) {
|
|
|
163
192
|
}
|
|
164
193
|
|
|
165
194
|
// Rebuilds `fieldId` as a <select> (data present) or a plain text <input>
|
|
166
|
-
// (no data for the parent selection)
|
|
167
|
-
//
|
|
168
|
-
//
|
|
195
|
+
// (no data for the parent selection). The new element replaces the old
|
|
196
|
+
// one, so every attribute is carried over (id, name, class, required,
|
|
197
|
+
// aria-*, Stimulus target data attributes, ...) — otherwise validation,
|
|
198
|
+
// accessibility wiring and Stimulus targets are lost after the first
|
|
199
|
+
// rebuild. Tom Select instances are torn down first and re-created on
|
|
200
|
+
// the new node.
|
|
169
201
|
function rebuildField(fieldId, pairs, preselectValue, placeholder) {
|
|
170
202
|
var el = document.getElementById(fieldId);
|
|
171
203
|
if (!el) return;
|
|
@@ -174,18 +206,14 @@ export default function CountryStateSelect(options) {
|
|
|
174
206
|
|
|
175
207
|
if (!pairs || pairs.length === 0) {
|
|
176
208
|
var input = document.createElement('input');
|
|
209
|
+
copyAttributes(el, input);
|
|
177
210
|
input.type = 'text';
|
|
178
|
-
input.id = fieldId;
|
|
179
|
-
input.name = el.name;
|
|
180
|
-
input.className = el.className;
|
|
181
211
|
el.replaceWith(input);
|
|
182
212
|
return;
|
|
183
213
|
}
|
|
184
214
|
|
|
185
215
|
var select = document.createElement('select');
|
|
186
|
-
select
|
|
187
|
-
select.name = el.name;
|
|
188
|
-
select.className = el.className;
|
|
216
|
+
copyAttributes(el, select);
|
|
189
217
|
|
|
190
218
|
var blank = document.createElement('option');
|
|
191
219
|
blank.textContent = placeholder || '';
|
|
@@ -206,6 +234,12 @@ export default function CountryStateSelect(options) {
|
|
|
206
234
|
enhance(fieldId);
|
|
207
235
|
}
|
|
208
236
|
|
|
237
|
+
function copyAttributes(from, to) {
|
|
238
|
+
Array.prototype.forEach.call(from.attributes, function (attr) {
|
|
239
|
+
if (NON_TRANSFERABLE_ATTRIBUTES.indexOf(attr.name) === -1) to.setAttribute(attr.name, attr.value);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
209
243
|
function enhance(fieldId) {
|
|
210
244
|
if (!fieldId || typeof enhancer !== 'function') return;
|
|
211
245
|
var el = document.getElementById(fieldId);
|
data/docs/json-api.md
CHANGED
|
@@ -16,7 +16,7 @@ GET /find_states?country_id=US
|
|
|
16
16
|
[["AK", "Alaska"], ["AL", "Alabama"], ["AR", "Arkansas"], ...]
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Resolves through the configured `data_source`, so a custom adapter is used here too. The country settings (`only_countries`, `except_countries`, `priority_countries`) are not applied: they shape the country list, and this endpoint returns the states of whatever country code it is given.
|
|
20
20
|
|
|
21
21
|
## `GET /find_cities`
|
|
22
22
|
|
|
@@ -34,7 +34,9 @@ GET /find_cities?state_id=CA&country_id=US
|
|
|
34
34
|
|
|
35
35
|
## Caching
|
|
36
36
|
|
|
37
|
-
Both endpoints send `Cache-Control` and `ETag`
|
|
37
|
+
Both endpoints send `Cache-Control: public` and an `ETag` when `CountryStateSelect.configuration.cache_duration` is set (default: 1 day). Set `cache_duration = nil` to send neither.
|
|
38
|
+
|
|
39
|
+
The `ETag` is built from the gem version, the data source's `cache_key` and the request params. A conditional `GET` whose `If-None-Match` matches gets `304 Not Modified` with no body. Upgrading the gem (or the `city-state` gem, for the default data source) changes the `ETag`, so clients re-fetch. A custom data source whose data changes at runtime should return a changing `cache_key`; see [Custom data sources](../README.md#custom-data-sources).
|
|
38
40
|
|
|
39
41
|
## Routes
|
|
40
42
|
|
|
@@ -22,6 +22,14 @@ module CountryStateSelect
|
|
|
22
22
|
def cities(_state_code, _country_code)
|
|
23
23
|
raise NotImplementedError
|
|
24
24
|
end
|
|
25
|
+
|
|
26
|
+
# Part of the ETag on the JSON lookup endpoints. Change it whenever the
|
|
27
|
+
# underlying data changes (e.g. a table's max updated_at) so browsers
|
|
28
|
+
# holding a cached response re-fetch instead of getting a 304.
|
|
29
|
+
# @return [String]
|
|
30
|
+
def cache_key
|
|
31
|
+
self.class.name
|
|
32
|
+
end
|
|
25
33
|
end
|
|
26
34
|
end
|
|
27
35
|
end
|