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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d8f77a9628df0c6d9f3f67493a93621ea6f22a458a04c26969aecb71922778ff
4
- data.tar.gz: 050561b2a48f6ca39fde1287adf09236ed4a0e983effe55e19a59aa540e551dc
3
+ metadata.gz: '07096615806cb6927cc87f41dd9f52a468a4abbbc3995f14510e5d4a9c76586f'
4
+ data.tar.gz: 106a0cd51d710611182c4f07c2b28de1e2efde86bd0170f8ce0cedb784bfcee7
5
5
  SHA512:
6
- metadata.gz: 4a2487931fa56393723cd3d06d3d18449876c8bb743673583132aa8f7e098eb5b0cd82efd501ee2cf846cc2d627434f39d8f0e5dba03ed0997cd1b0f0a793f26
7
- data.tar.gz: 90efce81dba401f57355c7186f7857145b92fc4f5a2afccb95f83b6489a6f6f5612d4c03bf5eb37f387cecf1d25857f139f59744d0a06abf0f81fe7183db8044
6
+ metadata.gz: 4b478c6e74ed49cde30de69c0695d6ba82b32931bf778acd723d848b1e749f5389c359010f4c32a4753c8cea682839d28c1e741fe4f083eb8c03d2a6f065a23f
7
+ data.tar.gz: e3f8d5c2f1aebee96f819354a56d9d0d227c490134d4f8c1a012e434ad0500b7cad1227f604a0bd155b9d7a82f9258f239054daeb93bbbe437854440e780038d
@@ -0,0 +1,41 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request:
7
+
8
+ jobs:
9
+ ruby-versions:
10
+ name: Ruby ${{ matrix.ruby }} (latest Rails)
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ ruby: ['3.1', '3.2', '3.3', '3.4']
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: ruby/setup-ruby@v1
19
+ with:
20
+ ruby-version: ${{ matrix.ruby }}
21
+ bundler-cache: true
22
+ - name: Run tests
23
+ run: bundle exec rspec
24
+
25
+ rails-versions:
26
+ name: Rails ${{ matrix.rails }} (Ruby 3.3)
27
+ runs-on: ubuntu-latest
28
+ strategy:
29
+ fail-fast: false
30
+ matrix:
31
+ rails: ['7.0', '7.1', '7.2', '8.0']
32
+ env:
33
+ BUNDLE_GEMFILE: gemfiles/rails_${{ matrix.rails }}.gemfile
34
+ steps:
35
+ - uses: actions/checkout@v4
36
+ - uses: ruby/setup-ruby@v1
37
+ with:
38
+ ruby-version: '3.3'
39
+ bundler-cache: true
40
+ - name: Run tests
41
+ run: bundle exec rspec
data/.gitignore CHANGED
@@ -21,3 +21,5 @@ tmp
21
21
  *.a
22
22
  mkmf.log
23
23
  .idea/
24
+ vendor/bundle/
25
+ gemfiles/*.gemfile.lock
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require spec_helper
data/CHANGELOG.md ADDED
@@ -0,0 +1,56 @@
1
+ # Changelog
2
+
3
+ ## 4.0.0
4
+
5
+ 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.
6
+
7
+ ### Added
8
+ - Dependency-free JavaScript core (`app/javascript/country_state_select/country_state_select.js`), with builds for importmap-rails/npm (ESM) and Sprockets (`vendor/assets/javascript/country_state_select_vanilla.js`, compiled via `rake js:build`).
9
+ - A Stimulus controller (`country_state_select/controller`) with `states-url`/`cities-url`/`tom-select`/`announce`/`state-placeholder`/`city-placeholder` values.
10
+ - npm package (`package.json`) exposing the core and Stimulus controller as ES modules; `@hotwired/stimulus` is an optional peer dependency.
11
+ - Native `form_with`/`form_for` support: `f.country_select`, `f.state_select`, `f.city_select` — simple_form is no longer required. They render the model's current selection server-side (correct on edit forms without waiting on JS) and stamp Stimulus target data attributes automatically.
12
+ - `rails g country_state_select:install` generator (initializer + setup instructions).
13
+ - `CountryStateSelect.configure` with `priority_countries`, `only_countries`/`except_countries`, `flags`, `dial_codes`, `localize_names`, `cache_duration`, `draw_routes`, and a pluggable `data_source` (`CountryStateSelect::DataSources::Base`).
14
+ - `only:`/`except:`/`priority:` keyword options on `countries_collection`, `countries_except`, `collect_states`.
15
+ - Flag emoji and international dial code label decoration for countries.
16
+ - I18n-localized country names, with an optional soft integration with the `countries` gem.
17
+ - HTTP caching (`Cache-Control` + `ETag`, conditional `GET` → `304`) on the `/find_states`/`/find_cities` JSON endpoints; configurable via `cache_duration`.
18
+ - `config.draw_routes = false` + documented pattern for mounting the lookup routes at a custom path.
19
+ - An `aria-live` region announcing dropdown updates, and edit-form pre-population support in the new JS (skips a redundant AJAX round-trip when the server already rendered the correct selection; only fetches when a field is genuinely still a blank shell).
20
+ - `spec/dummy`: a runnable Rails 7/8 demo app (Propshaft + importmap + Stimulus) that doubles as the integration test target.
21
+ - CI matrix expanded to Rails 7.0/7.1/7.2/8.0 in addition to the Ruby version matrix (`gemfiles/`).
22
+ - `docs/json-api.md`, `docs/cities.md`, `docs/migration-v4.md`.
23
+
24
+ ### Changed
25
+ - **Requires Rails >= 7.0 and Ruby >= 3.1.** Rails 3–6 support (`engine3.rb`, `railtie.rb`) removed — stay on `country_state_select` 3.x for older Rails.
26
+ - `cities_collection` (the simple_form helper) now actually passes the country through to the lookup, instead of silently ignoring it — see the migration guide if you relied on the old (incorrect) behavior.
27
+ - `/find_states` and `/find_cities` now resolve through the configured data source and `only`/`except`/`priority` filters rather than calling the underlying data gem directly.
28
+
29
+ ### Deprecated
30
+ - The jQuery + Chosen JavaScript (`vendor/assets/javascript/country_state_select.js.erb`) is kept for backward compatibility but is deprecated in favor of the new dependency-free build.
31
+
32
+ ## 3.3.1
33
+
34
+ ### Fixed
35
+ - **CSRF / verb:** the `find_states` and `find_cities` lookup routes now accept
36
+ `GET` (the correct verb for read-only data) in addition to `POST`, and the
37
+ controller skips forgery protection. Previously the front-end's AJAX `POST`
38
+ had no CSRF token and would raise `ActionController::InvalidAuthenticityToken`
39
+ on a default Rails app. The bundled JavaScript now issues `GET` requests.
40
+ - **Data mutation:** `countries_collection` no longer calls `except!` (bang) on
41
+ the hash that `city-state` memoizes, which permanently mutated the gem's
42
+ shared country data for the process. It now uses a non-mutating `reject`.
43
+ - **Consistent return shape:** `collect_cities` now always returns an `Array`
44
+ (empty when there is no data), matching `collect_states`, instead of `nil`.
45
+ - **Duplicate AJAX requests:** the state-change handler that triggers the city
46
+ lookup is now bound once via event delegation instead of being re-bound on
47
+ every country change (the old behaviour stacked listeners and fired multiple
48
+ requests per change — the long-standing `[142] FIXME`).
49
+ - **JavaScript hardening:** option markup now quotes the `value` attribute and
50
+ HTML-escapes labels; `html` and loop counters are properly scoped instead of
51
+ leaking to the global object.
52
+
53
+ ### Changed
54
+ - CI moved to GitHub Actions running the RSpec suite across Ruby 3.0–3.3. The
55
+ unused Travis and CircleCI configs (the latter never ran the tests) were
56
+ removed.
data/README.md CHANGED
@@ -1,155 +1,240 @@
1
- ## Country State Select
2
- [![Build Status](https://travis-ci.org/arvindvyas/Country-State-Select.svg?branch=master)](https://travis-ci.org/arvindvyas/Country-State-Select)
1
+ ## Country State Select
2
+ [![CI](https://github.com/arvindvyas/Country-State-Select/actions/workflows/ci.yml/badge.svg)](https://github.com/arvindvyas/Country-State-Select/actions/workflows/ci.yml)
3
3
  [![Code Climate](https://codeclimate.com/github/arvindvyas/Country-State-Select/badges/gpa.svg)](https://codeclimate.com/github/arvindvyas/Country-State-Select)
4
4
 
5
+ 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.
5
6
 
7
+ ![Cascading country, state, and city selects](docs/images/cascading-select-demo.png)
6
8
 
7
- Country State Select is a library that provides an easy API to generate Country , State / Province and City dropdowns for use in forms.
9
+ Country/state/state data comes from the [city-state](https://github.com/loureirorg/city-state) gem (pluggable see [Custom data sources](#custom-data-sources)).
8
10
 
9
- When implemented correctly, a State / Province dropdown is filled with appropriate regions based upon what Country a user has selected .
11
+ **v4.0 is a modernization release.** Highlights:
10
12
 
11
- For instance, if a user chooses "United States of America" for a Country dropdown, the State dropdown will be filled with the 50 appropriate states plus the District of Columbia also then user can list city according to state selection but currently cities are limited.
13
+ - Dependency-free JavaScript (no jQuery) with a bundled [Stimulus](https://stimulus.hotwired.dev/) controller
14
+ - Works with importmap-rails, Sprockets, Propshaft, or npm/esbuild/webpack
15
+ - Native `form_with`/`form_for` support (`f.country_select`, `f.state_select`, `f.city_select`) — simple_form is no longer required
16
+ - `priority_countries`, `only`/`except` filtering, flags, dial codes, I18n-localized names
17
+ - A pluggable data source adapter
18
+ - HTTP-cached JSON lookup endpoints
19
+ - An install generator (`rails g country_state_select:install`)
12
20
 
13
- The data for Countries and States is populated by the excellent [city-state](https://github.com/loureirorg/city-state) gem.
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).
14
22
 
15
- # Demo
16
- https://country-state-select.herokuapp.com
23
+ ## Installation
17
24
 
18
- # Getting Started
25
+ ```ruby
26
+ gem 'country_state_select'
27
+ ```
19
28
 
20
- Country State Select is released as a Ruby Gem and requires the Rails environment as well.
29
+ ```
30
+ bundle install
31
+ rails g country_state_select:install # optional: initializer + setup instructions
32
+ ```
21
33
 
22
- To install, simply add the following to your Gemfile.
34
+ Requires Rails >= 7.0 and Ruby >= 3.1.
23
35
 
24
- gem 'country_state_select'
25
-
26
- Then run
27
-
28
- bundle install
36
+ ## JavaScript setup
29
37
 
30
- # NOTE
31
- In the latest version we have removed all the dependencies of js and you are feel free to use any js you can use predefine method to populate data
38
+ Pick the one that matches your app. All of them talk to the same `/find_states` and `/find_cities` JSON endpoints (see [docs/json-api.md](docs/json-api.md)).
32
39
 
33
- Don't forget to restart your server after you install it.
34
- ## Setup Gem
35
- if not autoloaded than please add in your application.js
36
- //= require country_state_select
40
+ ### Importmap + Stimulus (Rails 7/8 default)
37
41
 
38
- ## chosen JavaScript assets
39
- The application makes use of the chosen-rails library for a better user experience. This can be disabled if desired.
40
-
41
- If you're using chosen-rails, please add the following to app/assets/javascripts/application.js:
42
-
43
- //= require chosen-jquery
42
+ The engine registers its pins automatically — no `pin` lines to write yourself. In `app/javascript/application.js`:
44
43
 
44
+ ```js
45
+ import "country_state_select"
46
+ import { Application } from "@hotwired/stimulus"
47
+ import CountryStateSelectController from "country_state_select/controller"
45
48
 
46
- ## chosen CSS assets
49
+ const application = Application.start()
50
+ application.register("country-state-select", CountryStateSelectController)
51
+ ```
47
52
 
48
- You'll also need to add the chosen-rails stylesheet.
53
+ Or, with `stimulus-rails`' `app/javascript/controllers/index.js` auto-loading convention, just re-export it as its own controller file:
49
54
 
50
- If you're using chosen-rails, please add the following to app/assets/stylesheets/application.css:
55
+ ```js
56
+ // app/javascript/controllers/country_state_select_controller.js
57
+ export { default } from "country_state_select/controller"
58
+ ```
51
59
 
52
- *= require chosen
60
+ Then in a view, wrap your fields in a controller container:
53
61
 
54
- or
55
-
56
- @import "chosen";
57
-
58
-
59
- # Form Implementation in SimpleForm
62
+ ```erb
63
+ <div data-controller="country-state-select">
64
+ <%= f.country_select :country %>
65
+ <%= f.state_select :state, :country %>
66
+ <%= f.city_select :city, :state, :country %>
67
+ </div>
68
+ ```
60
69
 
61
- Implementation in a view is simple.
70
+ Stimulus controller values (all optional, set as `data-country-state-select-<value>-value="..."`):
62
71
 
63
- Just create a simple_form object like so:
64
- ```ruby
65
- <%= simple_form_for @form_object do |f|
66
- f.button :submit
67
- end %>
68
- ```
69
- Once your form object is created, add in fields for your user to select a Country and State / Province.
72
+ | Value | Default | Purpose |
73
+ |---|---|---|
74
+ | `states-url` | `/find_states` | Override if you disabled `draw_routes` and mounted the engine elsewhere |
75
+ | `cities-url` | `/find_cities` | Same, for cities |
76
+ | `tom-select` | `false` | Enhance the selects with [Tom Select](https://tom-select.js.org/) once populated (requires `TomSelect` to be loaded globally) |
77
+ | `announce` | `true` | Announce dropdown updates via an `aria-live` region |
78
+ | `state-placeholder` / `city-placeholder` | `""` | Blank option text shown before a real selection |
70
79
 
71
- ## Country Field
80
+ ### Sprockets (no jQuery)
72
81
 
73
- To add the Country field, add an input field like the following:
82
+ ```js
83
+ //= require country_state_select_vanilla
84
+ ```
74
85
 
75
- <%= f.input :country_field, collection: CountryStateSelect.countries_collection %>
76
-
77
- The above code is simply a normal simple_form field object with a collection of countries passed to it using the CountryStateSelect library.
86
+ ```js
87
+ document.addEventListener('DOMContentLoaded', function () {
88
+ CountryStateSelect({ country_id: "country_field_id", state_id: "state_field_id" })
89
+ })
90
+ ```
78
91
 
79
- You can pass any of the normal options that a field object accepts - :label, :input_html, etc.
80
-
81
- ## States / Provinces Field
92
+ ### npm / esbuild / webpack
82
93
 
83
- To list the States / Provinces, add an input field like the following:
94
+ ```
95
+ npm install country_state_select @hotwired/stimulus
96
+ ```
84
97
 
85
- <%= options = { form: f, field_names: { :country => :country_field, :state => :state_field } }
86
-
87
- f.input :state_field, CountryStateSelect.state_options(options) %>
88
-
89
- It's important to note that the :country and :state keys in the :field_names object must contain the correct values in order for the correct States / Provinces to load into the window.
98
+ ```js
99
+ import CountryStateSelectController from "country_state_select/controller"
100
+ // or, for the framework-agnostic core directly:
101
+ import CountryStateSelect from "country_state_select"
102
+ ```
90
103
 
91
- Other than that, it's yet another standard simple_form field object.
104
+ `@hotwired/stimulus` is an optional peer dependency only needed if you import the `/controller` entry point.
92
105
 
93
- Any options that are valid in simple_form field can be passed into the options hash.
106
+ ### Legacy jQuery + Chosen (deprecated)
94
107
 
95
- # Required JavaScript
108
+ Still bundled for apps upgrading from 3.x. New apps should use one of the options above.
96
109
 
97
- The magic of the library is the JavaScript that is performed on the fields. This JavaScript needs to be included in order for the proper State / Provinces to be selected based upon your Country selection.
110
+ ```js
111
+ //= require country_state_select
112
+ //= require chosen-jquery // optional, if you want the Chosen UI
113
+ ```
98
114
 
99
- Add this JavaScript to the page that is loading your form:
100
- ```javascript
101
- $(document).on('ready page:load', function() {
102
- return CountryStateSelect({
115
+ ```js
116
+ $(document).on('turbo:load', function () {
117
+ CountryStateSelect({
103
118
  country_id: "country_field_id",
104
- state_id: "state_field_id"
105
- });
106
- });
119
+ state_id: "state_field_id",
120
+ chosen_ui: true,
121
+ chosen_options: { disable_search_threshold: 10 }
122
+ })
123
+ })
107
124
  ```
108
- If you are using CoffeeScript, use the following:
109
- ```coffescript
110
- $(document).on 'ready page:load', ->
111
- CountryStateSelect({ country_id: "country_field_id", state_id: "state_field_id" })
112
- ```
113
125
 
114
- The country_id accepts the form ID for the country field. The state_id accepts the form ID for the state field.
126
+ ## Using it in a form
127
+
128
+ ### `form_with` (no simple_form required)
115
129
 
116
- The easiest way to determine the ID is to view the source in the web browser. (Or, you could set it in the options hash.)
130
+ ```erb
131
+ <%= form_with model: @business do |f| %>
132
+ <div data-controller="country-state-select">
133
+ <%= f.country_select :country, include_blank: true %>
134
+ <%= f.state_select :state, :country %>
135
+ <%= f.city_select :city, :state, :country %>
136
+ </div>
137
+ <% end %>
138
+ ```
117
139
 
118
- The ID being referred to is like the following in a select menu:
140
+ - `state_select`/`city_select` read the model's *current* country/state to render the right options server-side — on an edit form, the saved state and city show up correctly without waiting on JavaScript.
141
+ - When the parent selection has no states/cities (e.g. a country with no states), the field renders as a plain text input, same as before.
142
+ - `only:`, `except:`, and `priority:` options work on all three: `f.country_select :country, priority: %w[US CA]`.
119
143
 
120
- <select id="country_field_id"></select>
121
-
122
- # chosen UI improvements (optional)
144
+ ### simple_form (unchanged from 3.x)
123
145
 
124
- The CountryStateSelect interface offers the option to turn the Chosen library on or off.
146
+ ```erb
147
+ <%= simple_form_for @form_object do |f| %>
148
+ <%= f.input :country_field, collection: CountryStateSelect.countries_collection %>
125
149
 
126
- To turn it on, set it to true:
127
- ```javascript
128
- $(document).on('ready page:load', function() {
129
- return CountryStateSelect({
130
- chosen_ui: true,
131
- chosen_options: {
132
- disable_search_threshold: 10,
133
- width: '95%'
134
- },
135
- ...
136
- ...
137
- ...
138
- });
139
- });
140
- ```
141
- To turn it off, set it to false:
142
- ```javascript
143
- $(document).on('ready page:load', function() {
144
- return CountryStateSelect({
145
- chosen_ui: false,
146
- ...
147
- ...
148
- ...
149
- });
150
- });
151
- ```
150
+ <% options = { form: f, field_names: { country: :country_field, state: :state_field } } %>
151
+ <%= f.input :state_field, CountryStateSelect.state_options(options) %>
152
+ <% end %>
153
+ ```
154
+
155
+ City support: see [docs/cities.md](docs/cities.md).
156
+
157
+ ## Configuration
158
+
159
+ ```ruby
160
+ # config/initializers/country_state_select.rb
161
+ CountryStateSelect.configure do |config|
162
+ config.priority_countries = %w[US CA GB] # shown first in the dropdown
163
+ config.only_countries = %w[US CA GB IN] # restrict the list
164
+ config.except_countries = %w[KP] # or exclude specific ones
165
+
166
+ config.flags = true # "🇮🇳 India"
167
+ config.dial_codes = true # "India (+91)"
168
+ config.localize_names = true # translate names via I18n / the `countries` gem
169
+
170
+ config.cache_duration = 1.day # HTTP cache on /find_states, /find_cities; nil disables it
171
+ config.draw_routes = true # set false to mount the engine at a custom path instead
172
+ end
173
+ ```
174
+
175
+ Every option here can also be passed per-call: `CountryStateSelect.countries_collection(priority: %w[US])`, `f.country_select :country, only: %w[US CA]`.
176
+
177
+ ### Custom data sources
178
+
179
+ Swap out `city-state` (e.g. for a database table, or the `countries` gem) by implementing the adapter interface:
180
+
181
+ ```ruby
182
+ class MyDataSource < CountryStateSelect::DataSources::Base
183
+ def countries
184
+ { US: 'United States', IN: 'India' } # code => English name
185
+ end
186
+
187
+ def states(country_code)
188
+ [[:CA, 'California'], [:NY, 'New York']] # [code, name] pairs
189
+ end
190
+
191
+ def cities(state_code, country_code)
192
+ ['Los Angeles', 'San Francisco'] # plain names
193
+ end
194
+ end
195
+
196
+ CountryStateSelect.configure { |c| c.data_source = MyDataSource.new }
197
+ ```
198
+
199
+ ### Custom route path
200
+
201
+ By default the engine draws `/find_states` and `/find_cities` at the top level. To mount it elsewhere:
202
+
203
+ ```ruby
204
+ # config/initializers/country_state_select.rb
205
+ CountryStateSelect.configure { |c| c.draw_routes = false }
206
+ ```
207
+
208
+ ```ruby
209
+ # config/routes.rb
210
+ scope path: '/csc' do
211
+ match 'find_states' => 'country_state_select/cscs#find_states', via: :get
212
+ match 'find_cities' => 'country_state_select/cscs#find_cities', via: :get
213
+ end
214
+ ```
215
+
216
+ Then pass the matching URLs to the JS: `states_url: '/csc/find_states'` (or the Stimulus `states-url-value`).
217
+
218
+ ## JSON API
219
+
220
+ The lookup endpoints are documented in [docs/json-api.md](docs/json-api.md) — useful if you're driving the selects from a SPA or mobile client instead of the bundled JS.
221
+
222
+ ## Development
223
+
224
+ ```
225
+ bundle install
226
+ bundle exec rspec
227
+ ```
228
+
229
+ The dummy Rails app used for integration tests lives in `spec/dummy` and doubles as a runnable demo:
230
+
231
+ ```
232
+ cd spec/dummy
233
+ bin/rails server
234
+ ```
235
+
236
+ CI runs the suite across Ruby 3.1–3.4 (latest Rails) and Rails 7.0/7.1/7.2/8.0 (see `gemfiles/`).
152
237
 
153
238
  ## Contributing
154
- Fork, fix, then send a pull request.
155
-
239
+
240
+ Fork, fix, then send a pull request.
data/Rakefile CHANGED
@@ -1,3 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'bundler/gem_tasks'
4
+
5
+ namespace :js do
6
+ desc 'Compile app/javascript/country_state_select/country_state_select.js into the ' \
7
+ 'Sprockets-loadable vendor/assets/javascript/country_state_select_vanilla.js build'
8
+ task :build do
9
+ source_path = File.expand_path('app/javascript/country_state_select/country_state_select.js', __dir__)
10
+ dest_path = File.expand_path('vendor/assets/javascript/country_state_select_vanilla.js', __dir__)
11
+
12
+ source = File.read(source_path)
13
+ body = source.sub('export default function CountryStateSelect(options) {', 'function CountryStateSelect(options) {')
14
+ raise "Could not find the expected export in #{source_path} — did the function signature change?" if body == source
15
+
16
+ File.write(dest_path, <<~JS)
17
+ // GENERATED FILE — do not edit directly.
18
+ // Source: app/javascript/country_state_select/country_state_select.js
19
+ // Regenerate with `rake js:build` after editing the source.
20
+ (function (global) {
21
+ #{body.gsub(/^/, ' ').rstrip}
22
+
23
+ global.CountryStateSelect = CountryStateSelect;
24
+ })(this);
25
+ JS
26
+
27
+ puts "Wrote #{dest_path}"
28
+ end
29
+ end
@@ -3,20 +3,43 @@
3
3
  # @author : Arvind Vyas
4
4
  module CountryStateSelect
5
5
  class CscsController < ApplicationController
6
- def find_states
7
- csc = CS.states(params[:country_id])
6
+ # These are read-only data lookups served over GET, so CSRF tokens are not
7
+ # required. Skipping forgery protection also keeps the endpoints usable when
8
+ # a host app still wires them up as POST without sending a token.
9
+ skip_forgery_protection if respond_to?(:skip_forgery_protection)
8
10
 
9
- respond_to do |format|
10
- format.json { render json: csc.to_a }
11
- end
11
+ def find_states
12
+ # `[[code, name], ...]` shape — the front-end uses element 0 as the option
13
+ # value (ISO code) and element 1 as the label. Goes through the
14
+ # configured data source (not `CS` directly) so a custom adapter and
15
+ # only/except/priority filtering apply here too.
16
+ render_cached(CountryStateSelect.raw_states(params[:country_id]), params[:country_id])
12
17
  end
13
18
 
14
- # Sent it to state_id and country id it will return cities of that states
19
+ # Send it a state_id and country_id; returns the cities of that state.
15
20
  def find_cities
16
- cities = CS.cities(params[:state_id].to_sym, params[:country_id].to_sym)
21
+ render_cached(
22
+ CountryStateSelect.collect_cities(params[:state_id], params[:country_id]),
23
+ [params[:state_id], params[:country_id]]
24
+ )
25
+ end
26
+
27
+ private
28
+
29
+ # The underlying data is static per params, so this is safe to cache
30
+ # both at the HTTP layer (Cache-Control) and via conditional GET
31
+ # (ETag/304), avoiding a render entirely on repeat lookups. Disabled by
32
+ # setting `CountryStateSelect.configuration.cache_duration = nil`.
33
+ def render_cached(data, cache_key)
34
+ duration = CountryStateSelect.configuration.cache_duration
35
+
36
+ if duration
37
+ expires_in duration, public: true
38
+ return unless stale?(etag: cache_key)
39
+ end
17
40
 
18
41
  respond_to do |format|
19
- format.json { render json: cities.to_a }
42
+ format.json { render json: data }
20
43
  end
21
44
  end
22
45
  end
@@ -0,0 +1,66 @@
1
+ // Stimulus wrapper around country_state_select.js — the idiomatic way to
2
+ // wire this up on Rails 7/8 with Hotwire/Turbo and no manual JS per page.
3
+ //
4
+ // <div data-controller="country-state-select"
5
+ // data-country-state-select-tom-select-value="true">
6
+ // <select data-country-state-select-target="country" ...>
7
+ // <select data-country-state-select-target="state" ...>
8
+ // <select data-country-state-select-target="city" ...> (optional)
9
+ // </div>
10
+ //
11
+ // Register it once in your controllers/index.js:
12
+ //
13
+ // import { Application } from '@hotwired/stimulus'
14
+ // import CountryStateSelectController from 'country_state_select/controller'
15
+ // const application = Application.start()
16
+ // application.register('country-state-select', CountryStateSelectController)
17
+ //
18
+ // Requires `@hotwired/stimulus` as a peer dependency in the host app —
19
+ // it is not bundled or re-exported by this package.
20
+ //
21
+ // Imports the bare specifier "country_state_select" (the importmap pin /
22
+ // npm package name) rather than a relative path — a relative import would
23
+ // resolve against this file's own fingerprinted asset URL under Sprockets/
24
+ // Propshaft (e.g. controller-a1b2c3.js), bypassing the import map entirely
25
+ // and 404ing, since browsers only consult import maps for bare specifiers.
26
+ import { Controller } from '@hotwired/stimulus';
27
+ import CountryStateSelect from 'country_state_select';
28
+
29
+ export default class CountryStateSelectController extends Controller {
30
+ static targets = ['country', 'state', 'city'];
31
+ static values = {
32
+ statesUrl: { type: String, default: '/find_states' },
33
+ citiesUrl: { type: String, default: '/find_cities' },
34
+ tomSelect: { type: Boolean, default: false },
35
+ announce: { type: Boolean, default: true },
36
+ statePlaceholder: { type: String, default: '' },
37
+ cityPlaceholder: { type: String, default: '' }
38
+ };
39
+
40
+ connect() {
41
+ this.instance = CountryStateSelect({
42
+ country_id: this.countryTarget.id,
43
+ state_id: this.stateTarget.id,
44
+ city_id: this.hasCityTarget ? this.cityTarget.id : undefined,
45
+ states_url: this.statesUrlValue,
46
+ cities_url: this.citiesUrlValue,
47
+ state_placeholder: this.statePlaceholderValue,
48
+ city_placeholder: this.cityPlaceholderValue,
49
+ announce: this.announceValue,
50
+ enhance: this.tomSelectValue ? this.tomSelectEnhancer.bind(this) : undefined
51
+ });
52
+ }
53
+
54
+ disconnect() {
55
+ if (this.instance) this.instance.destroy();
56
+ }
57
+
58
+ tomSelectEnhancer(selectEl, opts) {
59
+ if (typeof TomSelect === 'undefined') {
60
+ console.warn('country-state-select: tomSelectValue is true but TomSelect is not loaded'); // eslint-disable-line no-console
61
+ return null;
62
+ }
63
+
64
+ return new TomSelect(selectEl, opts);
65
+ }
66
+ }