pico_phone-rails 0.3.0 → 0.5.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: 0524cbb733d2edc10a4a6f0fcb13197f2853d58f437286c7aea7bf4c30d53092
4
- data.tar.gz: b29da13e7d7308a7e4e60fda8be21fbd467a5e138e6d6509ce97a719cac77afd
3
+ metadata.gz: 8e4c80e86756e81b40665de3fe0c6436ee462b8d5ea011992e3a8507eaedb323
4
+ data.tar.gz: 8ce58cb67fc8b73d92ba3b945b303a5c7360265a6ac217e5c0e1e1806c2157b7
5
5
  SHA512:
6
- metadata.gz: c82086362cc5c8dcf692ba712c7a961b076c2d87becfd5d3ff9ccece17d7a1da095558d76748b83ecdee46fc6067c884671d6f681e2b037cf14fb06ffccc9e05
7
- data.tar.gz: 0cc7b9175ebdbef5c3cddbabae8fbb8b8705b5cd7a8ffbce7077b4ef46510f895401bd6699105601d8ce13730692f09c513e4503568fe3dd2e17c4850a80bd8f
6
+ metadata.gz: ca51d0d37018f62fceba7ac87b2f0ebd42f4e537b55d9814e33488b48ea30aa822fd18eef92713d4a9b464e8849298e91ca41b430d1f99d471aba28b371b494b
7
+ data.tar.gz: dba580ca4a43fe7b83e776cf4e8430a8ca19cf9fc97f0f059c423fdd4e6ae6117f9c1dd702b4ccf878a13f3a848ac271041b62e3350d9143b230cc16523f1d33
data/README.md CHANGED
@@ -181,6 +181,73 @@ reasoning as `containing_phone_number` above.
181
181
  every model, so identical names would collide and one would silently shadow
182
182
  the other.)
183
183
 
184
+ ### Form field helper
185
+
186
+ ```ruby
187
+ <%= pico_phone_field_tag :phone, @contact.phone, region: "US" %>
188
+ <%= f.pico_phone_field :phone, region: "US" %>
189
+ ```
190
+
191
+ Renders an `<input type="tel">` showing the number in national format when
192
+ it parses validly, or exactly what the user typed otherwise -- so re-editing
193
+ an invalid or partial number never shows a blank or garbled reformat. Works
194
+ whether the attribute is a plain string or already a `PicoPhone::PhoneNumber`
195
+ (via the attribute type above), and accepts `region:` as a String, Symbol
196
+ (instance method on the form's object), or Proc, same resolution rules as
197
+ extraction and the search index:
198
+
199
+ ```ruby
200
+ f.pico_phone_field :phone, region: ->(contact) { contact.organization.region }
201
+ ```
202
+
203
+ Named `pico_phone_field`/`pico_phone_field_tag` rather than Rails' own
204
+ `phone_field`/`f.phone_field` (an existing core alias for `telephone_field`,
205
+ a plain `<input type="tel">` with no formatting) -- redefining a core Rails
206
+ helper would silently change behavior for every `phone_field` call in an
207
+ app, not just ones backed by a PicoPhone-managed attribute.
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, reformatted to national format if what's there
243
+ parses validly, the same reformat-on-blur behavior as the plain
244
+ (non-`live`) helper.
245
+
246
+ `ValidationsController` uses your app's normal CSRF protection -- the
247
+ controller reads the token from the page's `<meta name="csrf-token">`
248
+ (rendered by Rails' own `csrf_meta_tags`, already in any standard layout)
249
+ and sends it with every request, no extra setup needed.
250
+
184
251
  ### ActiveJob serializer
185
252
 
186
253
  ```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
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PicoPhone
4
+ module Rails
5
+ # Computes what a phone field should display: the number's national
6
+ # format if it parses validly, the raw string otherwise -- so a user
7
+ # re-editing an invalid or partial number sees exactly what they typed,
8
+ # not a blank or garbled reformat. Shared by {FormHelper} and
9
+ # {FormBuilderExtension}.
10
+ #
11
+ # @param value [String, PicoPhone::PhoneNumber, nil]
12
+ # @param region [String, nil] ISO 3166-1 alpha-2 region for interpreting +value+ when it's a raw string
13
+ # @return [String, nil]
14
+ def self.phone_field_display_value(value, region)
15
+ return nil if value.nil?
16
+ return (value.valid? ? value.national : value.to_s) if value.is_a?(PicoPhone::PhoneNumber)
17
+
18
+ string = value.to_s
19
+ return string if string.empty?
20
+
21
+ phone_number = PicoPhone.parse(string, region)
22
+ phone_number.valid? ? phone_number.national : string
23
+ end
24
+
25
+ # @param region [String, nil]
26
+ # @param validate_path [String] the mounted engine's validate endpoint
27
+ # @return [Hash] Stimulus data attributes for +live:+ validation
28
+ def self.live_validation_data(region, validate_path)
29
+ {
30
+ controller: "phone",
31
+ action: "input->phone#validate blur->phone#reformat",
32
+ phone_region_value: region.to_s,
33
+ phone_url_value: validate_path
34
+ }
35
+ end
36
+
37
+ # Included into ActionView::Base by the railtie once ActionView loads.
38
+ # Adds +pico_phone_field_tag+, a +text_field_tag+-like helper that
39
+ # displays national format for a valid number while leaving the
40
+ # underlying +<input>+ free to submit whatever string the user types.
41
+ #
42
+ # Named distinctly from Rails' own +phone_field_tag+/+f.phone_field+
43
+ # (a built-in alias for +telephone_field+, plain +<input type="tel">+
44
+ # with no formatting) rather than overriding it -- redefining a core
45
+ # Rails helper would silently change behavior for every +phone_field+
46
+ # call in an app, not just ones backed by a PicoPhone-managed attribute.
47
+ #
48
+ # @example
49
+ # pico_phone_field_tag :phone, @contact.phone, region: "US"
50
+ module FormHelper
51
+ # @param name [String, Symbol]
52
+ # @param value [String, PicoPhone::PhoneNumber, nil]
53
+ # @param region [String, nil] ISO 3166-1 alpha-2 region for interpreting +value+ when it's a raw string
54
+ # @param live [Boolean] wire up debounced validation/reformatting; requires the engine to be mounted
55
+ # @return [String] an HTML-safe +<input type="tel">+ tag
56
+ def pico_phone_field_tag(name, value = nil, region: nil, live: false, **options)
57
+ display_value = PicoPhone::Rails.phone_field_display_value(value, region)
58
+ if live
59
+ data = PicoPhone::Rails.live_validation_data(region, pico_phone_rails.validate_path)
60
+ options[:data] = data.merge(options[:data] || {})
61
+ end
62
+ text_field_tag(name, display_value, options.merge(type: "tel"))
63
+ end
64
+ end
65
+
66
+ # Included into ActionView::Helpers::FormBuilder by the railtie once
67
+ # ActionView loads. Adds +f.pico_phone_field+, mirroring
68
+ # {FormHelper#pico_phone_field_tag} but bound to the form's object.
69
+ #
70
+ # @example
71
+ # f.pico_phone_field :phone, region: "US"
72
+ # f.pico_phone_field :phone, region: ->(contact) { contact.organization.region }
73
+ module FormBuilderExtension
74
+ # @param method [Symbol] the attribute to render
75
+ # @param region [String, Symbol, Proc, nil] ISO 3166-1 alpha-2 region for interpreting the attribute's
76
+ # raw value when it isn't already a {PicoPhone::PhoneNumber} -- a String is used as-is, a Symbol is
77
+ # called as an instance method on the form's object, a Proc is called with the object, same resolution
78
+ # rules as {Extraction.extract_phone_numbers_from} and {PhoneSearchIndex.maintain_phone_search_index}
79
+ # @param live [Boolean] wire up debounced validation/reformatting; requires the engine to be mounted
80
+ # @return [String] an HTML-safe +<input type="tel">+ tag
81
+ def pico_phone_field(method, region: nil, live: false, **options)
82
+ resolved_region = PicoPhone::Rails.resolve_region(region, object)
83
+ display_value = PicoPhone::Rails.phone_field_display_value(object.public_send(method), resolved_region)
84
+ if live
85
+ data = PicoPhone::Rails.live_validation_data(resolved_region, @template.pico_phone_rails.validate_path)
86
+ options[:data] = data.merge(options[:data] || {})
87
+ end
88
+ text_field(method, options.merge(value: display_value, type: "tel"))
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,38 @@
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, 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
+ if (data.valid) this.element.value = data.national
22
+ }
23
+
24
+ async check() {
25
+ const headers = { "Content-Type": "application/json", Accept: "application/json" }
26
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
27
+ if (csrfToken) headers["X-CSRF-Token"] = csrfToken
28
+
29
+ const response = await fetch(this.urlValue, {
30
+ method: "POST",
31
+ headers,
32
+ body: JSON.stringify({ phone: this.element.value, region: this.regionValue }),
33
+ })
34
+ const data = await response.json()
35
+ if (this.errorElement) this.errorElement.textContent = data.valid || data.blank ? "" : data.message
36
+ return data
37
+ }
38
+ }
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ pin "pico_phone/rails/phone_controller", to: "pico_phone/rails/phone_controller.js"
@@ -37,6 +37,14 @@ module PicoPhone
37
37
  end
38
38
  end
39
39
 
40
+ initializer "pico_phone_rails.form_helper" do
41
+ ActiveSupport.on_load(:action_view) do
42
+ require "pico_phone/rails/form_helper"
43
+ include PicoPhone::Rails::FormHelper
44
+ ::ActionView::Helpers::FormBuilder.include PicoPhone::Rails::FormBuilderExtension
45
+ end
46
+ end
47
+
40
48
  initializer "pico_phone_rails.i18n" do |app|
41
49
  app.config.i18n.load_path += Dir[File.expand_path("locale/*.yml", __dir__)]
42
50
  end
@@ -0,0 +1,37 @@
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
+ phone_number = PicoPhone.parse(phone, params[:region].presence)
14
+
15
+ if phone_number.valid?
16
+ render json: {
17
+ valid: true,
18
+ blank: false,
19
+ e164: safe_phone_call(phone_number, :e164),
20
+ national: safe_phone_call(phone_number, :national)
21
+ }
22
+ else
23
+ render json: { valid: false, blank: false, message: I18n.t("errors.messages.invalid_phone") }
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ # @return [String, nil] nil if unparseable, matching PhoneSearchIndex's guard
30
+ def safe_phone_call(phone_number, method_name)
31
+ phone_number.public_send(method_name)
32
+ rescue TypeError
33
+ nil
34
+ end
35
+ end
36
+ end
37
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module PicoPhone
4
4
  module Rails
5
- VERSION = "0.3.0"
5
+ VERSION = "0.5.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.3.0
4
+ version: 0.5.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,8 +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
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
78
86
  - lib/pico_phone/rails/locale/en.yml
79
87
  - lib/pico_phone/rails/normalizer.rb
80
88
  - lib/pico_phone/rails/phone_search_index.rb
@@ -82,6 +90,7 @@ files:
82
90
  - lib/pico_phone/rails/region_resolution.rb
83
91
  - lib/pico_phone/rails/serializers/phone_number_serializer.rb
84
92
  - lib/pico_phone/rails/type.rb
93
+ - lib/pico_phone/rails/validations_controller.rb
85
94
  - lib/pico_phone/rails/version.rb
86
95
  homepage: https://github.com/gjack/pico_phone-rails
87
96
  licenses:
@@ -110,5 +119,6 @@ requirements: []
110
119
  rubygems_version: 3.6.9
111
120
  specification_version: 4
112
121
  summary: 'Rails integration for pico_phone: attribute type, validator, normalizer,
113
- 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'
114
124
  test_files: []