pico_phone-rails 0.4.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad67a4a67bfcdfd2e94072a2989dd25c23896ed5d97ef6b506a08c31548fe9f1
4
- data.tar.gz: 1414db39189fdaed84124aaf1e10e8abb5ed02414668130a9f37ceb4b682ccad
3
+ metadata.gz: a6cddadddef5d95e3c070bd31fb0399be628771ee521156dc1432c196cf938f7
4
+ data.tar.gz: da7202512a00127735002e1b8f0eb143a5e2466d8e712895f07cbe5eb11831cf
5
5
  SHA512:
6
- metadata.gz: 6aaeb277b412aa8acea4729c56a9e1c371a3e967cf4c4162d9aa430e1409842e075cf810ff2db665b605dff47ebd18e85d3367cbff4c7652fc6ed92563371700
7
- data.tar.gz: e31a7f628e3b16386fc79ccc3f0d9ceb67d41db69901ccbe7ff455475da9afc3e5a05fc316b5f85946071eb4e333bb7f9e0bb10a92955d3bb99bd5f721f89b7a
6
+ metadata.gz: d508fcda421a174e0d201aa23abf472534000a8f2f878e813bc0f9bba504545c93271bbfeb4283ecbcb960ad74d20f483406938d7a965f16ff1f0c24379cbc3c
7
+ data.tar.gz: 18ee00d8bc5a2cf4a6b02a22ed3f69249fc83cdeb3117826beb32afd4627d26d317893ecb5bec775f800273eecbf9170641f57661a3e28fb3717ca5d63bbf49b
data/README.md CHANGED
@@ -206,6 +206,76 @@ a plain `<input type="tel">` with no formatting) -- redefining a core Rails
206
206
  helper would silently change behavior for every `phone_field` call in an
207
207
  app, not just ones backed by a PicoPhone-managed attribute.
208
208
 
209
+ ### Live validation
210
+
211
+ Mount the engine:
212
+
213
+ ```ruby
214
+ # config/routes.rb
215
+ mount PicoPhone::Rails::Engine, at: "/pico_phone"
216
+ ```
217
+
218
+ The controller is auto-pinned into `importmap-rails` when present, but
219
+ still needs to be registered with your Stimulus application -- pinning
220
+ only makes it importable, the same as any other pinned module:
221
+
222
+ ```js
223
+ // app/javascript/controllers/index.js
224
+ import { application } from "controllers/application"
225
+ import PhoneController from "pico_phone/rails/phone_controller"
226
+ application.register("phone", PhoneController)
227
+ ```
228
+
229
+ Then opt in per field:
230
+
231
+ ```ruby
232
+ <%= f.pico_phone_field :phone, region: "US", live: true %>
233
+ <span data-phone-target="error"></span>
234
+ ```
235
+
236
+ Debounces input and POSTs to the mounted engine, showing the error message
237
+ in a sibling `data-phone-target="error"` element (anywhere under the same
238
+ parent as the field -- `data-controller` sits on the `<input>` itself,
239
+ which can't have descendants, so the target is found via the field's
240
+ parent rather than Stimulus's usual descendant-scoped target lookup)
241
+ while the user types. Never rewrites the field's value while it has
242
+ focus -- only on blur.
243
+
244
+ On blur, a number that matches `region:` reformats to national format, the
245
+ same as the plain (non-`live`) helper. A number that's valid for a
246
+ *different* country -- one that carries its own explicit signal, a leading
247
+ `+` or a recognized IDD exit code (e.g. `"011 44 20 7946 0958"` dialed out
248
+ of a US-configured field) -- reformats to **international** format instead,
249
+ so it's clearly shown as a foreign number rather than misleadingly bare
250
+ national-style digits. A bare national-style number with no country signal
251
+ of its own (e.g. `"020 7946 0958"` typed into a `region: "US"` field) is
252
+ left exactly as typed -- there's no reliable way to tell which of several
253
+ countries it might belong to from the digits alone, and guessing wrong
254
+ would mean showing the user a different, real phone number than the one
255
+ they meant.
256
+
257
+ Whether a cross-country match also clears the error is controlled by
258
+ `strict:`:
259
+
260
+ ```ruby
261
+ <%= f.pico_phone_field :phone, region: "US", live: true, strict: false %>
262
+ ```
263
+
264
+ - `strict: true` (default) -- only a same-region match clears the error.
265
+ A number valid for a different country still reformats to international,
266
+ but the error stays, matching a validator that enforces `region:`.
267
+ - `strict: false` -- any globally-valid number clears the error, matching
268
+ a validator that accepts any country (no `region:` option, or
269
+ `possible:` with no region constraint). **Keep `strict:` in sync with
270
+ whether the model's own `PhoneValidator` sets `region:`** -- pairing
271
+ `strict: false` here with a validator that still enforces `region:`
272
+ shows no error live, but the record still fails validation on submit.
273
+
274
+ `ValidationsController` uses your app's normal CSRF protection -- the
275
+ controller reads the token from the page's `<meta name="csrf-token">`
276
+ (rendered by Rails' own `csrf_meta_tags`, already in any standard layout)
277
+ and sends it with every request, no extra setup needed.
278
+
209
279
  ### ActiveJob serializer
210
280
 
211
281
  ```ruby
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ PicoPhone::Rails::Engine.routes.draw do
4
+ post "validate", to: "validations#validate"
5
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+ require "pico_phone/rails/validations_controller"
5
+
6
+ module PicoPhone
7
+ module Rails
8
+ class Engine < ::Rails::Engine
9
+ isolate_namespace PicoPhone::Rails
10
+
11
+ initializer "pico_phone_rails.assets" do |app|
12
+ app.config.assets.paths << Pathname.new(__dir__).join("javascript") if app.config.respond_to?(:assets)
13
+ end
14
+
15
+ # importmap-rails draws config.importmap.paths in its own "importmap"
16
+ # initializer -- appending after that is a no-op, so this must run before.
17
+ # A no-op itself if importmap-rails isn't installed.
18
+ initializer "pico_phone_rails.importmap", before: "importmap" do |app|
19
+ next unless app.config.respond_to?(:importmap)
20
+
21
+ app.config.importmap.paths << Pathname.new(__dir__).join("javascript.rb")
22
+ app.config.importmap.cache_sweepers << Pathname.new(__dir__).join("javascript")
23
+ end
24
+ end
25
+ end
26
+ end
@@ -22,6 +22,22 @@ module PicoPhone
22
22
  phone_number.valid? ? phone_number.national : string
23
23
  end
24
24
 
25
+ # @param region [String, nil]
26
+ # @param validate_path [String] the mounted engine's validate endpoint
27
+ # @param strict [Boolean] whether a number valid for a different country than +region+
28
+ # still counts as invalid (matching a validator that enforces +region:+) or clears
29
+ # the error (matching a validator that accepts any valid number)
30
+ # @return [Hash] Stimulus data attributes for +live:+ validation
31
+ def self.live_validation_data(region, validate_path, strict: true)
32
+ {
33
+ controller: "phone",
34
+ action: "input->phone#validate blur->phone#reformat",
35
+ phone_region_value: region.to_s,
36
+ phone_strict_value: strict,
37
+ phone_url_value: validate_path
38
+ }
39
+ end
40
+
25
41
  # Included into ActionView::Base by the railtie once ActionView loads.
26
42
  # Adds +pico_phone_field_tag+, a +text_field_tag+-like helper that
27
43
  # displays national format for a valid number while leaving the
@@ -39,9 +55,15 @@ module PicoPhone
39
55
  # @param name [String, Symbol]
40
56
  # @param value [String, PicoPhone::PhoneNumber, nil]
41
57
  # @param region [String, nil] ISO 3166-1 alpha-2 region for interpreting +value+ when it's a raw string
58
+ # @param live [Boolean] wire up debounced validation/reformatting; requires the engine to be mounted
59
+ # @param strict [Boolean] see {PicoPhone::Rails.live_validation_data}; only relevant when +live:+ is true
42
60
  # @return [String] an HTML-safe +<input type="tel">+ tag
43
- def pico_phone_field_tag(name, value = nil, region: nil, **options)
61
+ def pico_phone_field_tag(name, value = nil, region: nil, live: false, strict: true, **options)
44
62
  display_value = PicoPhone::Rails.phone_field_display_value(value, region)
63
+ if live
64
+ data = PicoPhone::Rails.live_validation_data(region, pico_phone_rails.validate_path, strict: strict)
65
+ options[:data] = data.merge(options[:data] || {})
66
+ end
45
67
  text_field_tag(name, display_value, options.merge(type: "tel"))
46
68
  end
47
69
  end
@@ -59,10 +81,17 @@ module PicoPhone
59
81
  # raw value when it isn't already a {PicoPhone::PhoneNumber} -- a String is used as-is, a Symbol is
60
82
  # called as an instance method on the form's object, a Proc is called with the object, same resolution
61
83
  # rules as {Extraction.extract_phone_numbers_from} and {PhoneSearchIndex.maintain_phone_search_index}
84
+ # @param live [Boolean] wire up debounced validation/reformatting; requires the engine to be mounted
85
+ # @param strict [Boolean] see {PicoPhone::Rails.live_validation_data}; only relevant when +live:+ is true
62
86
  # @return [String] an HTML-safe +<input type="tel">+ tag
63
- def pico_phone_field(method, region: nil, **options)
87
+ def pico_phone_field(method, region: nil, live: false, strict: true, **options)
64
88
  resolved_region = PicoPhone::Rails.resolve_region(region, object)
65
89
  display_value = PicoPhone::Rails.phone_field_display_value(object.public_send(method), resolved_region)
90
+ if live
91
+ validate_path = @template.pico_phone_rails.validate_path
92
+ data = PicoPhone::Rails.live_validation_data(resolved_region, validate_path, strict: strict)
93
+ options[:data] = data.merge(options[:data] || {})
94
+ end
66
95
  text_field(method, options.merge(value: display_value, type: "tel"))
67
96
  end
68
97
  end
@@ -0,0 +1,39 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // data-controller sits on the <input> itself, which can't have children --
4
+ // so the error element (a sibling, placed wherever the caller wants) is
5
+ // unreachable via Stimulus's normal descendant-scoped static targets.
6
+ // Found by hand instead, scoped to the input's parent.
7
+ export default class extends Controller {
8
+ static values = { url: String, region: String, strict: { type: Boolean, default: true }, debounce: { type: Number, default: 300 } }
9
+
10
+ connect() {
11
+ this.errorElement = this.element.parentElement?.querySelector('[data-phone-target="error"]') ?? null
12
+ }
13
+
14
+ validate() {
15
+ clearTimeout(this.timeout)
16
+ this.timeout = setTimeout(() => this.check(), this.debounceValue)
17
+ }
18
+
19
+ async reformat() {
20
+ const data = await this.check()
21
+ const formatted = data.national ?? data.international
22
+ if (formatted) this.element.value = formatted
23
+ }
24
+
25
+ async check() {
26
+ const headers = { "Content-Type": "application/json", Accept: "application/json" }
27
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
28
+ if (csrfToken) headers["X-CSRF-Token"] = csrfToken
29
+
30
+ const response = await fetch(this.urlValue, {
31
+ method: "POST",
32
+ headers,
33
+ body: JSON.stringify({ phone: this.element.value, region: this.regionValue, strict: this.strictValue }),
34
+ })
35
+ const data = await response.json()
36
+ if (this.errorElement) this.errorElement.textContent = data.valid || data.blank ? "" : data.message
37
+ return data
38
+ }
39
+ }
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ pin "pico_phone/rails/phone_controller", to: "pico_phone/rails/phone_controller.js"
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_controller"
4
+
5
+ module PicoPhone
6
+ module Rails
7
+ # `region:` always arrives as an already-resolved String -- no record lookup.
8
+ class ValidationsController < ActionController::Base
9
+ def validate
10
+ phone = params[:phone].to_s
11
+ return render json: { valid: false, blank: true } if phone.strip.empty?
12
+
13
+ render json: response_for(phone, params[:region].presence)
14
+ end
15
+
16
+ private
17
+
18
+ def response_for(phone, region)
19
+ regional = PicoPhone.parse(phone, region)
20
+ return regional_payload(regional) if matches_region?(regional, region)
21
+
22
+ # `regional`'s #valid?/#country are scoped to `region` even when the input carries its
23
+ # own country code (a leading "+" or a recognized IDD exit code, e.g. US's "011") --
24
+ # #valid_countries isn't, since it works off the actually-extracted country code, and
25
+ # #e164/#international are unaffected by the scoping either. No second parse needed.
26
+ # A bare national-style number with no country code of its own (e.g. "020 7946 0958"
27
+ # typed into a region: "US" field) stays invalid here -- #valid_countries comes back
28
+ # empty because there's no signal pointing at any specific country to check against.
29
+ return invalid_payload if regional.valid_countries.empty?
30
+
31
+ cross_country_payload(regional)
32
+ end
33
+
34
+ def matches_region?(phone_number, region)
35
+ region ? phone_number.valid_for_country?(region) : phone_number.valid?
36
+ end
37
+
38
+ def regional_payload(phone_number)
39
+ {
40
+ valid: true,
41
+ blank: false,
42
+ e164: safe_phone_call(phone_number, :e164),
43
+ national: safe_phone_call(phone_number, :national)
44
+ }
45
+ end
46
+
47
+ def cross_country_payload(phone_number)
48
+ strict = params.fetch(:strict, true)
49
+ payload = {
50
+ valid: !strict,
51
+ blank: false,
52
+ e164: safe_phone_call(phone_number, :e164),
53
+ international: safe_phone_call(phone_number, :international)
54
+ }
55
+ payload[:message] = invalid_message if strict
56
+ payload
57
+ end
58
+
59
+ def invalid_payload
60
+ { valid: false, blank: false, message: invalid_message }
61
+ end
62
+
63
+ def invalid_message
64
+ I18n.t("errors.messages.invalid_phone")
65
+ end
66
+
67
+ # @return [String, nil] nil if unparseable, matching PhoneSearchIndex's guard
68
+ def safe_phone_call(phone_number, method_name)
69
+ phone_number.public_send(method_name)
70
+ rescue TypeError
71
+ nil
72
+ end
73
+ end
74
+ end
75
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module PicoPhone
4
4
  module Rails
5
- VERSION = "0.4.0"
5
+ VERSION = "0.6.0"
6
6
  end
7
7
  end
@@ -16,3 +16,4 @@ require "pico_phone/rails/normalizer"
16
16
  require "pico_phone/rails/extraction"
17
17
  require "pico_phone/rails/phone_search_index"
18
18
  require "pico_phone/rails/railtie" if defined?(Rails::Railtie)
19
+ require "pico_phone/rails/engine" if defined?(Rails::Railtie)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pico_phone-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gabi Jack
@@ -56,8 +56,11 @@ description: 'pico_phone-rails wires the pico_phone gem into Rails: a :phone_num
56
56
  class macro that rewrites phone attributes to E.164 before validation, extract_phone_numbers_from
57
57
  for pulling phone numbers out of free text (with an optional persisted backend for
58
58
  cross-record search), maintain_phone_search_index for keeping search columns in
59
- sync on an existing phone-number table, and an ActiveJob serializer so a PhoneNumber
60
- survives being passed as a job argument.'
59
+ sync on an existing phone-number table, pico_phone_field_tag/f.pico_phone_field
60
+ form helpers that display national format for a valid number without discarding
61
+ what the user typed, live: true on those same helpers for debounced Stimulus-driven
62
+ validation and reformatting via a mountable engine, and an ActiveJob serializer
63
+ so a PhoneNumber survives being passed as a job argument.'
61
64
  email:
62
65
  - gabi@gabijack.com
63
66
  executables: []
@@ -66,6 +69,7 @@ extra_rdoc_files: []
66
69
  files:
67
70
  - LICENSE.txt
68
71
  - README.md
72
+ - config/routes.rb
69
73
  - lib/generators/pico_phone/rails/extracted_phone_numbers/extracted_phone_numbers_generator.rb
70
74
  - lib/generators/pico_phone/rails/extracted_phone_numbers/templates/create_pico_phone_rails_extracted_phone_numbers.rb.tt
71
75
  - lib/generators/pico_phone/rails/phone_number/phone_number_generator.rb
@@ -73,9 +77,12 @@ files:
73
77
  - lib/generators/pico_phone/rails/phone_number/templates/model.rb.tt
74
78
  - lib/phone_validator.rb
75
79
  - lib/pico_phone/rails.rb
80
+ - lib/pico_phone/rails/engine.rb
76
81
  - lib/pico_phone/rails/extracted_phone_number.rb
77
82
  - lib/pico_phone/rails/extraction.rb
78
83
  - lib/pico_phone/rails/form_helper.rb
84
+ - lib/pico_phone/rails/javascript.rb
85
+ - lib/pico_phone/rails/javascript/pico_phone/rails/phone_controller.js
79
86
  - lib/pico_phone/rails/locale/en.yml
80
87
  - lib/pico_phone/rails/normalizer.rb
81
88
  - lib/pico_phone/rails/phone_search_index.rb
@@ -83,6 +90,7 @@ files:
83
90
  - lib/pico_phone/rails/region_resolution.rb
84
91
  - lib/pico_phone/rails/serializers/phone_number_serializer.rb
85
92
  - lib/pico_phone/rails/type.rb
93
+ - lib/pico_phone/rails/validations_controller.rb
86
94
  - lib/pico_phone/rails/version.rb
87
95
  homepage: https://github.com/gjack/pico_phone-rails
88
96
  licenses:
@@ -111,5 +119,6 @@ requirements: []
111
119
  rubygems_version: 3.6.9
112
120
  specification_version: 4
113
121
  summary: 'Rails integration for pico_phone: attribute type, validator, normalizer,
114
- free-text extraction, phone search index, and ActiveJob serializer'
122
+ free-text extraction, phone search index, form field helpers, live validation, and
123
+ ActiveJob serializer'
115
124
  test_files: []