validation_hints 6.0.0 → 6.2.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: 1f43f9338ea91967565859b93f7a0f884cf4d3999d83abda9d23ac55673a77ec
4
- data.tar.gz: 399657680fb3f0a005aea6034c41a52c1f96b21c051e811b7dbe6f2c0d24fc0c
3
+ metadata.gz: 417c0455d8dedf1c33c7cf608af48ec92ecb6b1d913f60f58ac0d7b91d0eb58d
4
+ data.tar.gz: 216254f45fa84b4370828da2830d79689fbd3971da68fa0e4e891873ed40029f
5
5
  SHA512:
6
- metadata.gz: 0c3e3a58ea45b2620e516216d8d5267fefe2bff0c43c2de1956ab2bb3c15174418ed68f867c9d05bb56fa280107b8095afc9520b03f7dc93e231f445edad52cb
7
- data.tar.gz: 602bbff26e2aea34e3117f9223063a8ba461608d8f51da3b9bb357418260dc8abe9e9455772bfbfbed4404e80133cfa2714c67ed3347bb031aed6527d5efc951
6
+ metadata.gz: 2a961271d6a978a874745411c8251e8fb255c974917da3dbcaad7e469caada854ef7f9437d0de8972d266348a3d054e5952bb81cd42f5ed3515d1c45d1a435e6
7
+ data.tar.gz: 974c699d5c1acaf13dd0494156cd8d46f5628f6b9ef9d874c873804d33d75d6df745fd62fc5fa8db41210cca236f9a5642df2da571dd55ecb5e245527417326d
data/CHANGELOG.md CHANGED
@@ -2,7 +2,47 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
- ## 6.0.0 (unreleased)
5
+ ## 6.2.0
6
+
7
+ I18n lookup chain and documentation (Phase 2).
8
+
9
+ ### Added
10
+
11
+ - `activemodel.hints` and `activerecord.hints` locale scopes (format + lookup chain).
12
+ - `test/active_model/i18n_test.rb` — app override and `human_attribute_name` coverage.
13
+ - README: requirements, API, I18n lookup order, and `config/locales` override examples.
14
+
15
+ ### Changed
16
+
17
+ - `generate_message` / `full_message` I18n fallback chain mirrors Rails errors (with `hints` namespace).
18
+ - `full_message` passes `base: @base` to `human_attribute_name` and resolves format via scoped `hints.format` keys.
19
+
20
+ ## 6.1.0
21
+
22
+ Rails 7 compatibility for `ActiveModel::Hints` and expanded test coverage (Phase 1).
23
+
24
+ ### Added
25
+
26
+ - `test/active_model/hints_test.rb` — presence, length, numericality, inclusion, virtual attributes, `empty?`, `add`/`added?`.
27
+ - In-memory SQLite ActiveRecord setup in `test/test_helper.rb`.
28
+ - `normalize_message` for mutable hint APIs (`add`, `added?`).
29
+ - `length.within` locale key with `%{minimum}` / `%{maximum}` interpolation.
30
+ - Default hint messages: `invalid`, `blank`, `empty`.
31
+
32
+ ### Changed
33
+
34
+ - `ActiveModel::Hints` modernized for Rails 7:
35
+ - plain `Hash` instead of `OrderedHash`
36
+ - fixed `empty?` / `blank?`
37
+ - `generate_message` aligned with Rails 7 I18n chain and interpolation
38
+ - validator keys via `demodulize.underscore.delete_suffix("_validator")`
39
+ - attribute discovery includes virtual attributes from validators
40
+ - length min+max combined into a single `within` hint
41
+ - presence hint suppressed when validator has `allow_blank: true`
42
+ - `locale/en.yml` cleaned up (removed stray keys; format copy → “must match the required format”).
43
+ - Documented behavior: conditional validators (`:if`, `:unless`, `:on`) are not evaluated.
44
+
45
+ ## 6.0.0
6
46
 
7
47
  Modernize the gem for **Ruby 3.2+** and **Rails 7.0.x**, targeting use with **inline_forms 7.x**.
8
48
 
@@ -14,20 +54,19 @@ Modernize the gem for **Ruby 3.2+** and **Rails 7.0.x**, targeting use with **in
14
54
 
15
55
  ### Added
16
56
 
17
- - `ValidationHints::Railtie` — registers I18n and patches `ActiveModel::Validations` via `ActiveSupport.on_load(:active_model)` (Rails apps no longer need a manual `require`).
57
+ - `ValidationHints::Railtie` — registers I18n and patches `ActiveModel::Validations` via `ActiveSupport.on_load(:active_model)`.
18
58
  - `ValidationHints::ValidationsPatch` — extracted module for `has_validations*` and `hints`.
19
59
  - `ValidationHints.load_i18n!` — idempotent locale registration.
20
60
  - Runtime dependency on `activerecord` 7.0.x.
21
61
  - Minitest harness (`test/`) and `rake test` as the default Rake task.
22
62
  - `CHANGELOG.md`.
63
+ - `rake release` / gem packaging tasks via `Bundler::GemHelper`.
23
64
 
24
65
  ### Changed
25
66
 
26
- - `ValidationHints::VERSION` set to `6.0.0`.
27
- - Gemspec rewritten for current RubyGems / Bundler (Ruby version, dependencies, file list with non-git fallback).
67
+ - Gemspec rewritten for current RubyGems / Bundler.
28
68
  - `Gemfile` source updated to `https://rubygems.org`.
29
- - Entry point (`lib/validation_hints.rb`) refactored: Railtie in Rails, direct `active_model` load outside Rails.
30
- - I18n locale registration moved behind `ValidationHints.load_i18n!` (was unconditional append at load time).
69
+ - Entry point refactored: Railtie in Rails, direct `active_model` load outside Rails.
31
70
 
32
71
  ### Removed
33
72
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- validation_hints (6.0.0)
4
+ validation_hints (6.2.0)
5
5
  activerecord (>= 7.0, < 7.1)
6
6
 
7
7
  GEM
@@ -264,7 +264,7 @@ CHECKSUMS
264
264
  thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
265
265
  timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
266
266
  tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
267
- validation_hints (6.0.0)
267
+ validation_hints (6.2.0)
268
268
  websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962
269
269
  websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241
270
270
  zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd
data/README.md CHANGED
@@ -1,34 +1,93 @@
1
+ # validation_hints
1
2
 
2
- ## Validation Hints
3
+ Proactive validation hints derived from model validators — complementary to Rails `errors` (which react after `valid?` fails).
3
4
 
4
- Delivers hints derived from the validation on a model.
5
+ **Requirements:** Ruby >= 3.2, Rails / Active Record 7.0.x (`>= 7.0`, `< 7.1`). Used by [inline_forms](https://github.com/acesuares/inline_forms).
5
6
 
6
- ### Install
7
+ ## Install
7
8
 
8
9
  ```ruby
9
- gem 'validation_hints'
10
+ # Gemfile
11
+ gem "validation_hints", "~> 6.2"
10
12
  ```
11
13
 
12
- ### Example
14
+ Rails apps load the gem via `ValidationHints::Railtie` (no manual `require`).
15
+
16
+ ## Example
13
17
 
14
18
  ```ruby
15
- class Person < ActiveRecord::Base
16
- validates :name, :presence => true
17
- validates :password, :length => { :within => 1...5 }
19
+ class Person < ApplicationRecord
20
+ validates :name, presence: true
21
+ validates :password, length: { within: 1...5 }
18
22
  end
23
+
24
+ Person.new.hints[:name] # => ["can't be blank"]
25
+ Person.new.hints[:password] # => ["must be between 1 and 4 characters"]
26
+
27
+ Person.new.hints.full_messages_for(:name) # => ["Name can't be blank"]
28
+ Person.new.has_validations_for?(:name) # => true
19
29
  ```
20
30
 
21
- ```ruby
22
- Person.new.hints[:name] => ["can't be blank"]
23
- Person.new.hints[:password] => ["must not be shorter than 1 characters", "must not be longer than 4 characters"]
24
- Person.new.hints.messages => {:id=>[], :password=>["must not be shorter than 1 characters", "must not be longer than 4 characters"], :name => ["can't be blank"] }
31
+ ## API (stable)
32
+
33
+ | Method | Description |
34
+ |--------|-------------|
35
+ | `model.hints` | `ActiveModel::Hints` for the instance |
36
+ | `hints[:attribute]` | Short hint strings for an attribute |
37
+ | `hints.full_messages_for(attr)` | Attribute label + hint (used by inline_forms tooltips) |
38
+ | `has_validations?` / `has_validations_for?(attr)` | Whether to show hint UI |
39
+
40
+ ## I18n
41
+
42
+ Default copy lives in the gem at `lib/validation_hints/locale/en.yml` under the `hints` namespace.
43
+
44
+ Lookup order mirrors Rails **errors**, but uses **`hints`** instead of `errors`:
45
+
46
+ 1. `activerecord.hints.models.<model>.attributes.<attr>.<type>`
47
+ 2. `activerecord.hints.models.<model>.<type>`
48
+ 3. `activerecord.hints.messages.<type>`
49
+ 4. `activemodel.hints.messages.<type>`
50
+ 5. `hints.attributes.<attr>.<type>`
51
+ 6. `hints.messages.<type>`
52
+
53
+ Full messages (`full_messages_for`) use `hints.format` with the same scoping (`activerecord.hints.format`, `activemodel.hints.format`, `hints.format`).
54
+
55
+ ### App overrides
56
+
57
+ Add a locale file, e.g. `config/locales/validation_hints.en.yml`:
58
+
59
+ ```yaml
60
+ en:
61
+ activerecord:
62
+ hints:
63
+ format: "%{attribute}: %{message}"
64
+ messages:
65
+ presence: "is required"
66
+ models:
67
+ apartment:
68
+ attributes:
69
+ name:
70
+ presence: "enter a name for this apartment"
25
71
  ```
26
72
 
27
- ### Disclaimer
73
+ Per-attribute overrides without a model block:
74
+
75
+ ```yaml
76
+ en:
77
+ hints:
78
+ attributes:
79
+ name:
80
+ presence: "is required"
81
+ ```
28
82
 
29
- It's work in progress.
30
- validation_hints was for the most part derived from activerecord-3.2.3/lib/active_record/errors.rb
83
+ Attribute labels in full messages come from the normal Rails path (`activerecord.attributes.<model>.<attr>`), same as errors.
31
84
 
85
+ ## Tests
32
86
 
87
+ ```bash
88
+ bundle exec rake test
89
+ ```
33
90
 
91
+ ## History
34
92
 
93
+ See [CHANGELOG.md](CHANGELOG.md).
@@ -1,67 +1,37 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ActiveModel
2
- # == Active Model Hints
4
+ # Introspects declared validators and builds proactive hint messages (before +valid?+ fails).
3
5
  #
4
- # p = Person.new
5
- # p.hints
6
- # p.hints[:name]
7
- #
8
- # more documentation needed
9
-
6
+ # Conditional options (+:if+, +:unless+, +:on+) are not evaluated; hints reflect static rules.
7
+ # For +format+ validators, prefer a custom +message:+ or per-attribute I18n keys.
10
8
  class Hints
11
9
  include Enumerable
12
10
 
13
- CALLBACKS_OPTIONS = [:if, :unless, :on, :allow_nil, :allow_blank, :strict]
14
- MESSAGES_FOR_VALIDATORS = %w(confirmation acceptance presence uniqueness format associated numericality)
15
- VALIDATORS_WITHOUT_MAIN_KEYS = %w(exclusion format inclusion length numericality)
16
- # and these? validates_with validates_each
17
- MESSAGES_FOR_OPTIONS = %w(within in is minimum maximum greater_than greater_than_or_equal_to equal_to less_than less_than_or_equal_to odd even only_integer)
18
- OPTIONS_THAT_WE_DONT_USE_YET = {
19
- :acceptance => :acceptance
11
+ MESSAGES_FOR_OPTIONS = %w[
12
+ within in is minimum maximum greater_than greater_than_or_equal_to
13
+ equal_to less_than less_than_or_equal_to odd even only_integer
14
+ ].freeze
20
15
 
21
- }
22
- VALIDATORS_THAT_WE_DONT_KNOW_WHAT_TO_DO_WITH = %w(validates_associated)
16
+ VALIDATORS_WITHOUT_MAIN_KEYS = %w[exclusion format inclusion length numericality].freeze
23
17
 
24
- # Should virtual element for
25
- # validates :email, :confirmation => true
26
- # validates :email_confirmation, :presence => true
27
- # also have a hint?
18
+ RANGE_OPTIONS = %w[within in].freeze
28
19
 
29
20
  attr_reader :messages
30
21
 
31
- # Pass in the instance of the object that is using the errors object.
32
- #
33
- # class Person
34
- # def initialize
35
- # @errors = ActiveModel::Errors.new(self)
36
- # end
37
- # end
38
22
  def initialize(base)
39
- @base = base
40
- @messages = ActiveSupport::OrderedHash.new
41
- @base.attributes.keys.each do |a|
42
- @messages[a.to_sym] = hints_for(a.to_sym)
23
+ @base = base
24
+ @messages = {}
25
+ attribute_names_for_hints.each do |attribute|
26
+ @messages[attribute] = hints_for(attribute)
43
27
  end
44
28
  end
45
29
 
46
30
  def hints_for(attribute)
47
- result = Array.new
48
- @base.class.validators_on(attribute).map do |v|
49
- validator = v.class.to_s.split('::').last.underscore.gsub('_validator','')
50
- if v.options[:message].is_a?(Symbol)
51
- message_key = [validator, v.options[:message]].join('.') # if a message was supplied as a symbol, we use it instead
52
- result << generate_message(attribute, message_key, v.options)
53
- else
54
- message_key = validator
55
- message_key = [validator, ".must_be_a_number"].join('.') if validator == 'numericality' # create an option for numericality; the way YAML works a key (numericality) with subkeys (greater_than, etc etc) can not have a string itself. So we create a subkey for numericality
56
- result << generate_message(attribute, message_key, v.options) unless VALIDATORS_WITHOUT_MAIN_KEYS.include?(validator)
57
- v.options.each do |o|
58
- if MESSAGES_FOR_OPTIONS.include?(o.first.to_s)
59
- count = o.last
60
- count = (o.last.to_sentence if %w(inclusion exclusion).include?(validator)) rescue o.last
61
- result << generate_message(attribute, [ validator, o.first.to_s ].join('.'), { :count => count } )
62
- end
63
- end
64
- end
31
+ attribute = attribute.to_sym
32
+ result = []
33
+ @base.class.validators_on(attribute).each do |validator|
34
+ result.concat(messages_for_validator(attribute, validator))
65
35
  end
66
36
  result
67
37
  end
@@ -71,143 +41,74 @@ module ActiveModel
71
41
  end
72
42
 
73
43
  def initialize_dup(other)
74
- @messages = other.messages.dup
44
+ @messages = other.messages.transform_values(&:dup)
75
45
  end
76
46
 
77
- # Backport dup from 1.9 so that #initialize_dup gets called
78
- unless Object.respond_to?(:initialize_dup)
79
- def dup # :nodoc:
80
- copy = super
81
- copy.initialize_dup(self)
82
- copy
83
- end
84
- end
85
-
86
- # Clear the messages
87
47
  def clear
88
48
  messages.clear
89
49
  end
90
50
 
91
- # Do the hint messages include an hint with key +hint+?
92
- def include?(hint)
93
- (v = messages[hint]) && v.any?
51
+ def include?(attribute)
52
+ (value = messages[attribute.to_sym]) && value.any?
94
53
  end
95
- alias :has_key? :include?
54
+ alias has_key? include?
96
55
 
97
- # Get messages for +key+
98
56
  def get(key)
99
57
  messages[key]
100
58
  end
101
59
 
102
- # Set messages for +key+ to +value+
103
60
  def set(key, value)
104
61
  messages[key] = value
105
62
  end
106
63
 
107
- # Delete messages for +key+
108
64
  def delete(key)
109
65
  messages.delete(key)
110
66
  end
111
67
 
112
- # When passed a symbol or a name of a method, returns an array of hints
113
- # for the method.
114
- #
115
- # p.hints[:name] # => ["can not be nil"]
116
- # p.hints['name'] # => ["can not be nil"]
117
68
  def [](attribute)
118
69
  get(attribute.to_sym) || set(attribute.to_sym, [])
119
70
  end
120
71
 
121
- # Adds to the supplied attribute the supplied hint message.
122
- #
123
- # p.hints[:name] = "must be set"
124
- # p.hints[:name] # => ['must be set']
125
72
  def []=(attribute, hint)
126
73
  self[attribute] << hint
127
74
  end
128
75
 
129
- # Iterates through each hint key, value pair in the hint messages hash.
130
- # Yields the attribute and the hint for that attribute. If the attribute
131
- # has more than one hint message, yields once for each hint message.
132
- #
133
- # p.hints.add(:name, "can't be blank")
134
- # p.hints.each do |attribute, hints_array|
135
- # # Will yield :name and "can't be blank"
136
- # end
137
- #
138
- # p.hints.add(:name, "must be specified")
139
- # p.hints.each do |attribute, hints_array|
140
- # # Will yield :name and "can't be blank"
141
- # # then yield :name and "must be specified"
142
- # end
143
76
  def each
144
77
  messages.each_key do |attribute|
145
78
  self[attribute].each { |hint| yield attribute, hint }
146
79
  end
147
80
  end
148
81
 
149
- # Returns the number of error messages.
150
- #
151
- # p.hints.add(:name, "can't be blank")
152
- # p.hints.size # => 1
153
- # p.hints.add(:name, "must be specified")
154
- # p.hints.size # => 2
155
82
  def size
156
83
  values.flatten.size
157
84
  end
158
85
 
159
- # Returns all message values
160
86
  def values
161
87
  messages.values
162
88
  end
163
89
 
164
- # Returns all message keys
165
90
  def keys
166
91
  messages.keys
167
92
  end
168
93
 
169
- # Returns an array of hint messages, with the attribute name included
170
- #
171
- # p.hints.add(:name, "can't be blank")
172
- # p.hints.add(:name, "must be specified")
173
- # p.hints.to_a # => ["name can't be blank", "name must be specified"]
174
94
  def to_a
175
95
  full_messages
176
96
  end
177
97
 
178
- # Returns the number of hint messages.
179
- # p.hints.add(:name, "can't be blank")
180
- # p.hints.count # => 1
181
- # p.hints.add(:name, "must be specified")
182
- # p.hints.count # => 2
183
98
  def count
184
99
  to_a.size
185
100
  end
186
101
 
187
- # Returns true if no hints are found, false otherwise.
188
- # If the hint message is a string it can be empty.
189
102
  def empty?
190
- all? { |k, v| v && v.empty? && !v.is_a?(String) }
103
+ messages.values.all?(&:empty?)
191
104
  end
192
105
  alias_method :blank?, :empty?
193
106
 
194
- # Returns an xml formatted representation of the hints hash.
195
- #
196
- # p.hints.add(:name, "can't be blank")
197
- # p.hints.add(:name, "must be specified")
198
- # p.hints.to_xml
199
- # # =>
200
- # # <?xml version=\"1.0\" encoding=\"UTF-8\"?>
201
- # # <hints>
202
- # # <hint>name can't be blank</hint>
203
- # # <hint>name must be specified</hint>
204
- # # </hints>
205
- def to_xml(options={})
206
- to_a.to_xml options.reverse_merge(:root => "hints", :skip_types => true)
207
- end
208
-
209
- # Returns an ActiveSupport::OrderedHash that can be used as the JSON representation for this object.
210
- def as_json(options=nil)
107
+ def to_xml(options = {})
108
+ to_a.to_xml(options.reverse_merge(root: "hints", skip_types: true))
109
+ end
110
+
111
+ def as_json(_options = nil)
211
112
  to_hash
212
113
  end
213
114
 
@@ -215,13 +116,7 @@ module ActiveModel
215
116
  messages.dup
216
117
  end
217
118
 
218
- # Adds +message+ to the hint messages on +attribute+. More than one hint can be added to the same
219
- # +attribute+.
220
- # If no +message+ is supplied, <tt>:invalid</tt> is assumed.
221
- #
222
- # If +message+ is a symbol, it will be translated using the appropriate scope (see +translate_hint+).
223
- # If +message+ is a proc, it will be called, allowing for things like <tt>Time.now</tt> to be used within an hint.
224
- def add(attribute, message = nil, options = {})
119
+ def add(attribute, message = :invalid, options = {})
225
120
  message = normalize_message(attribute, message, options)
226
121
  if options[:strict]
227
122
  raise ActiveModel::StrictValidationFailed, full_message(attribute, message)
@@ -230,95 +125,213 @@ module ActiveModel
230
125
  self[attribute] << message
231
126
  end
232
127
 
233
- # Will add an hint message to each of the attributes in +attributes+ that is empty.
234
128
  def add_on_empty(attributes, options = {})
235
- [attributes].flatten.each do |attribute|
129
+ Array(attributes).each do |attribute|
236
130
  value = @base.send(:read_attribute_for_validation, attribute)
237
131
  is_empty = value.respond_to?(:empty?) ? value.empty? : false
238
132
  add(attribute, :empty, options) if value.nil? || is_empty
239
133
  end
240
134
  end
241
135
 
242
- # Will add an hint message to each of the attributes in +attributes+ that is blank (using Object#blank?).
243
136
  def add_on_blank(attributes, options = {})
244
- [attributes].flatten.each do |attribute|
137
+ Array(attributes).each do |attribute|
245
138
  value = @base.send(:read_attribute_for_validation, attribute)
246
139
  add(attribute, :blank, options) if value.blank?
247
140
  end
248
141
  end
249
142
 
250
- # Returns true if an hint on the attribute with the given message is present, false otherwise.
251
- # +message+ is treated the same as for +add+.
252
- # p.hints.add :name, :blank
253
- # p.hints.added? :name, :blank # => true
254
- def added?(attribute, message = nil, options = {})
143
+ def added?(attribute, message = :invalid, options = {})
255
144
  message = normalize_message(attribute, message, options)
256
- self[attribute].include? message
257
- end
258
-
259
- # Returns all the full hint messages in an array.
260
- #
261
- # class Company
262
- # validates_presence_of :name, :address, :email
263
- # validates_length_of :name, :in => 5..30
264
- # end
265
- #
266
- # company = Company.create(:address => '123 First St.')
267
- # company.hints.full_messages # =>
268
- # ["Name is too short (minimum is 5 characters)", "Name can't be blank", "Email can't be blank"]
145
+ self[attribute].include?(message)
146
+ end
147
+
269
148
  def full_messages
270
149
  map { |attribute, message| full_message(attribute, message) }
271
150
  end
272
151
 
273
- # Returns a full message for a given attribute.
274
- #
275
- # company.hints.full_message(:name, "is invalid") # =>
276
- # "Name is invalid"
277
152
  def full_message(attribute, message)
278
153
  return message if attribute == :base
279
- attr_name = attribute.to_s.gsub('.', '_').humanize
280
- attr_name = @base.class.human_attribute_name(attribute, :default => attr_name)
281
- I18n.t(:"hints.format",
282
- :default => "%{attribute} %{message}",
283
- :attribute => attr_name,
284
- :message => message
285
- )
154
+
155
+ attr_name = attribute.to_s.tr(".", "_").humanize
156
+ attr_name = @base.class.human_attribute_name(attribute, default: attr_name, base: @base)
157
+
158
+ format_defaults = i18n_format_defaults
159
+ format_key = format_defaults.shift
160
+
161
+ I18n.t(
162
+ format_key,
163
+ default: format_defaults,
164
+ attribute: attr_name,
165
+ message: message
166
+ )
286
167
  end
287
168
 
288
169
  def generate_message(attribute, type, options = {})
289
- #options.delete(:message) if options[:message].is_a?(Symbol)
290
- if @base.class.respond_to?(:i18n_scope)
291
- defaults = @base.class.lookup_ancestors.map do |klass|
292
- [ :"#{@base.class.i18n_scope}.hints.models.#{klass.model_name.i18n_key}.attributes.#{attribute}.#{type}",
293
- :"#{@base.class.i18n_scope}.hints.models.#{klass.model_name.i18n_key}.#{type}" ]
170
+ type = options.delete(:message) if options[:message].is_a?(Symbol)
171
+ value = (attribute != :base ? @base.read_attribute_for_validation(attribute) : nil)
172
+
173
+ interpolation = {
174
+ model: @base.model_name.human,
175
+ attribute: @base.class.human_attribute_name(attribute, base: @base),
176
+ value: value,
177
+ object: @base,
178
+ count: options[:count],
179
+ minimum: options[:minimum],
180
+ maximum: options[:maximum]
181
+ }.compact
182
+
183
+ defaults = i18n_defaults(attribute, type, options)
184
+ key = defaults.shift
185
+
186
+ I18n.translate(key, **interpolation.merge(default: defaults))
187
+ end
188
+
189
+ private
190
+
191
+ def attribute_names_for_hints
192
+ from_record =
193
+ if @base.respond_to?(:attributes)
194
+ @base.attributes.keys
195
+ else
196
+ []
197
+ end
198
+
199
+ from_validators = @base.class.validators.flat_map(&:attributes).map(&:to_s)
200
+ (from_record + from_validators).map(&:to_sym).uniq
201
+ end
202
+
203
+ def messages_for_validator(attribute, validator)
204
+ key = validator_key(validator)
205
+ options = validator.options
206
+ result = []
207
+
208
+ if options[:allow_blank] && key == "presence"
209
+ return result
210
+ end
211
+
212
+ if options[:message].is_a?(Symbol)
213
+ message_key = "#{key}.#{options[:message]}"
214
+ result << generate_message(attribute, message_key, options)
215
+ return result
216
+ end
217
+
218
+ message_key = key
219
+ message_key = "numericality.must_be_a_number" if key == "numericality"
220
+ unless VALIDATORS_WITHOUT_MAIN_KEYS.include?(key)
221
+ result << generate_message(attribute, message_key, options)
222
+ end
223
+
224
+ if key == "length" && options[:minimum] && options[:maximum]
225
+ result << generate_message(
226
+ attribute,
227
+ "length.within",
228
+ minimum: options[:minimum],
229
+ maximum: options[:maximum]
230
+ )
231
+ return result
232
+ end
233
+
234
+ options.each do |option, value|
235
+ next unless MESSAGES_FOR_OPTIONS.include?(option.to_s)
236
+
237
+ if RANGE_OPTIONS.include?(option.to_s) && value.is_a?(Range)
238
+ result.concat(range_hint_messages(attribute, key, value))
239
+ else
240
+ count = inclusion_exclusion_count(key, value)
241
+ result << generate_message(
242
+ attribute,
243
+ "#{key}.#{option}",
244
+ options.merge(count: count)
245
+ )
294
246
  end
247
+ end
248
+
249
+ result
250
+ end
251
+
252
+ def range_hint_messages(attribute, validator_key, range)
253
+ minimum = range.min
254
+ maximum = range.max
255
+ maximum -= 1 if range.exclude_end?
256
+
257
+ if validator_key == "length"
258
+ [
259
+ generate_message(attribute, "#{validator_key}.within", minimum: minimum, maximum: maximum),
260
+ ]
295
261
  else
296
- defaults = []
262
+ [
263
+ generate_message(attribute, "#{validator_key}.minimum", count: minimum),
264
+ generate_message(attribute, "#{validator_key}.maximum", count: maximum)
265
+ ]
266
+ end
267
+ end
268
+
269
+ def inclusion_exclusion_count(validator_key, value)
270
+ return value.to_sentence if %w[inclusion exclusion].include?(validator_key) && value.respond_to?(:to_sentence)
271
+
272
+ value
273
+ end
274
+
275
+ def validator_key(validator)
276
+ validator.class.name.demodulize.underscore.delete_suffix("_validator")
277
+ end
278
+
279
+ def i18n_defaults(attribute, type, options)
280
+ attribute_name = attribute.to_s.remove(/\[\d+\]/)
281
+
282
+ defaults = model_hint_defaults(attribute_name, type)
283
+ defaults << options[:message] if options[:message]
284
+
285
+ if @base.class.respond_to?(:i18n_scope)
286
+ scope = @base.class.i18n_scope
287
+ defaults << :"#{scope}.hints.messages.#{type}"
297
288
  end
298
289
 
299
- defaults << options[:message] # defaults << options.delete(:message)
300
- defaults << :"#{@base.class.i18n_scope}.hints.messages.#{type}" if @base.class.respond_to?(:i18n_scope)
301
- defaults << :"hints.attributes.#{attribute}.#{type}"
290
+ defaults << :"activemodel.hints.messages.#{type}"
291
+ defaults << :"hints.attributes.#{attribute_name}.#{type}"
302
292
  defaults << :"hints.messages.#{type}"
293
+ defaults.compact.flatten
294
+ end
303
295
 
304
- defaults.compact!
305
- defaults.flatten!
296
+ def model_hint_defaults(attribute_name, type)
297
+ return [] unless @base.class.respond_to?(:i18n_scope)
306
298
 
307
- key = defaults.shift
299
+ scope = @base.class.i18n_scope
300
+ @base.class.lookup_ancestors.flat_map do |klass|
301
+ [
302
+ :"#{scope}.hints.models.#{klass.model_name.i18n_key}.attributes.#{attribute_name}.#{type}",
303
+ :"#{scope}.hints.models.#{klass.model_name.i18n_key}.#{type}"
304
+ ]
305
+ end
306
+ end
307
+
308
+ def i18n_format_defaults
309
+ defaults = []
308
310
 
309
- # options = {
310
- # :default => defaults,
311
- # :model => @base.class.model_name.human,
312
- # :attribute => @base.class.human_attribute_name(attribute),
313
- # }.merge(options)
314
- I18n.translate( key,
315
- default: defaults,
316
- model: @base.class.model_name.human,
317
- attribute: @base.class.human_attribute_name(attribute),
318
- count: options[:count],
319
- )
311
+ if @base.class.respond_to?(:i18n_scope)
312
+ scope = @base.class.i18n_scope
313
+ @base.class.lookup_ancestors.each do |klass|
314
+ defaults << :"#{scope}.hints.models.#{klass.model_name.i18n_key}.format"
315
+ end
316
+ defaults << :"#{scope}.hints.format"
317
+ end
318
+
319
+ defaults << :"activemodel.hints.format"
320
+ defaults << :"hints.format"
321
+ defaults << "%{attribute} %{message}"
320
322
  end
321
323
 
324
+ def normalize_message(attribute, message, options = {})
325
+ case message
326
+ when Symbol
327
+ generate_message(attribute, message, options)
328
+ when Proc
329
+ message.call
330
+ when nil
331
+ generate_message(attribute, :invalid, options)
332
+ else
333
+ message
334
+ end
335
+ end
322
336
  end
323
-
324
337
  end
@@ -1,21 +1,25 @@
1
- ## YAML Template.
2
1
  ---
3
2
  en:
3
+ activemodel:
4
+ hints:
5
+ format: "%{attribute} %{message}"
6
+
4
7
  activerecord:
5
- less_than_or_equal_to: "must be less than or equal to %{count}"
8
+ hints:
9
+ format: "%{attribute} %{message}"
10
+
6
11
  hints:
7
- # The default format to use in full error messages.
8
12
  format: "%{attribute} %{message}"
9
13
 
10
- # The values :model, :attribute and :value are always available for interpolation
11
- # The value :count is available when applicable. Can be used for pluralization.
12
14
  messages:
13
- is_a_curacao_id_number: 'hello world!'
15
+ invalid: "is invalid"
16
+ blank: "can't be blank"
17
+ empty: "can't be empty"
14
18
  inclusion:
15
19
  in: "must be one of %{count}"
16
20
  exclusion:
17
21
  in: "must not be one of %{count}"
18
- format: "what's this"
22
+ format: "must match the required format"
19
23
  associated: "is invalid"
20
24
  uniqueness: "must be unique"
21
25
  confirmation: "doesn't match confirmation"
@@ -25,7 +29,7 @@ en:
25
29
  maximum: "must not be longer than %{count} characters"
26
30
  minimum: "must not be shorter than %{count} characters"
27
31
  is: "must be exactly %{count} characters"
28
- radiolaria: "must be between %{minimum} and %{maximum} characters"
32
+ within: "must be between %{minimum} and %{maximum} characters"
29
33
  numericality:
30
34
  must_be_a_number: "must be a number"
31
35
  only_integer: "must be an integer"
@@ -36,4 +40,3 @@ en:
36
40
  less_than_or_equal_to: "must be less than or equal to %{count}"
37
41
  odd: "must be odd"
38
42
  even: "must be even"
39
-
@@ -1,4 +1,4 @@
1
1
  # -*- encoding : utf-8 -*-
2
2
  module ValidationHints
3
- VERSION = "6.0.0"
3
+ VERSION = "6.2.0"
4
4
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class ActiveModelHintsTest < Minitest::Test
6
+ def setup
7
+ @person = Person.new
8
+ @profile = Profile.new
9
+ end
10
+
11
+ def test_presence_hint
12
+ assert_includes @person.hints[:name], "can't be blank"
13
+ end
14
+
15
+ def test_full_messages_for_includes_human_attribute_name
16
+ messages = @person.hints.full_messages_for(:name)
17
+ assert_equal ["Name can't be blank"], messages
18
+ end
19
+
20
+ def test_has_validations_for
21
+ assert @person.has_validations_for?(:name)
22
+ refute @person.has_validations_for?(:missing)
23
+ end
24
+
25
+ def test_has_validations_on_class_and_instance
26
+ assert Person.has_validations?
27
+ assert @person.has_validations?
28
+ end
29
+
30
+ def test_length_within_hint
31
+ hints = @person.hints[:password]
32
+ assert_includes hints, "must be between 1 and 4 characters"
33
+ end
34
+
35
+ def test_numericality_option_hints
36
+ hints = @person.hints[:age]
37
+ assert_includes hints, "must be an integer"
38
+ assert_includes hints, "must be greater than 19"
39
+ end
40
+
41
+ def test_inclusion_hint_uses_list
42
+ hints = @person.hints[:status]
43
+ assert_equal 1, hints.size
44
+ assert_match(/must be one of/, hints.first)
45
+ assert_match(/active/, hints.first)
46
+ assert_match(/inactive/, hints.first)
47
+ end
48
+
49
+ def test_virtual_attribute_with_validators_is_included
50
+ assert_includes @profile.hints.keys, :nickname
51
+ assert_includes @profile.hints[:nickname], "can't be blank"
52
+ end
53
+
54
+ def test_empty_returns_true_when_no_hint_messages
55
+ person = Class.new do
56
+ include ActiveModel::Model
57
+ include ActiveModel::Validations
58
+
59
+ def self.name
60
+ "EmptyModel"
61
+ end
62
+ end.new
63
+
64
+ assert person.hints.empty?
65
+ end
66
+
67
+ def test_add_and_added_with_symbol_message
68
+ person = Person.new
69
+ person.hints.add(:name, :blank)
70
+ assert person.hints.added?(:name, :blank)
71
+ end
72
+
73
+ def test_validator_keys_for_rails_7_validators
74
+ validators = Person.validators_on(:name).map { |v| v.class.name.demodulize }
75
+ assert_includes validators, "PresenceValidator"
76
+ end
77
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class ActiveModelHintsI18nTest < Minitest::Test
6
+ def setup
7
+ @person = Person.new
8
+ @backend = I18n.backend
9
+ @kv = I18n::Backend::KeyValue.new({})
10
+ I18n.backend = I18n::Backend::Chain.new(@kv, @backend)
11
+ end
12
+
13
+ def teardown
14
+ I18n.backend = @backend
15
+ end
16
+
17
+ def test_activerecord_hints_messages_override
18
+ @kv.store_translations(:en, activerecord: { hints: { messages: { presence: "is required" } } })
19
+
20
+ assert_includes @person.hints[:name], "is required"
21
+ end
22
+
23
+ def test_activerecord_hints_model_attribute_override
24
+ @kv.store_translations(
25
+ :en,
26
+ activerecord: {
27
+ hints: {
28
+ models: {
29
+ person: {
30
+ attributes: {
31
+ name: { presence: "needs a name" }
32
+ }
33
+ }
34
+ }
35
+ }
36
+ }
37
+ )
38
+
39
+ assert_equal ["needs a name"], @person.hints[:name]
40
+ end
41
+
42
+ def test_hints_attributes_override
43
+ @kv.store_translations(:en, hints: { attributes: { name: { presence: "name hint" } } })
44
+
45
+ assert_equal ["name hint"], @person.hints[:name]
46
+ end
47
+
48
+ def test_top_level_hints_messages_override
49
+ @kv.store_translations(:en, hints: { messages: { presence: "fill this in" } })
50
+
51
+ assert_includes @person.hints[:name], "fill this in"
52
+ end
53
+
54
+ def test_full_message_uses_activerecord_human_attribute_name
55
+ @kv.store_translations(:en, activerecord: { attributes: { person: { name: "Your name" } } })
56
+
57
+ assert_equal ["Your name can't be blank"], @person.hints.full_messages_for(:name)
58
+ end
59
+
60
+ def test_full_message_format_override
61
+ @kv.store_translations(:en, activerecord: { hints: { format: "%{attribute}: %{message}" } })
62
+
63
+ assert_equal ["Name: can't be blank"], @person.hints.full_messages_for(:name)
64
+ end
65
+ end
data/test/test_helper.rb CHANGED
@@ -2,5 +2,33 @@
2
2
 
3
3
  require "bundler/setup"
4
4
  require "minitest/autorun"
5
+ require "active_record"
6
+ require "active_model"
5
7
 
6
8
  require "validation_hints"
9
+
10
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
11
+
12
+ ActiveRecord::Schema.define do
13
+ create_table :people, force: true do |t|
14
+ t.string :name
15
+ t.string :password
16
+ t.integer :age
17
+ t.string :status
18
+ end
19
+ end
20
+
21
+ class Person < ActiveRecord::Base
22
+ validates :name, presence: true
23
+ validates :password, length: { within: 1...5 }
24
+ validates :age, numericality: { only_integer: true, greater_than: 19 }
25
+ validates :status, inclusion: { in: %w[active inactive] }
26
+ end
27
+
28
+ class Profile < ActiveRecord::Base
29
+ self.table_name = "people"
30
+
31
+ attr_accessor :nickname
32
+
33
+ validates :nickname, presence: true
34
+ end
@@ -4,11 +4,10 @@ require "test_helper"
4
4
 
5
5
  class ValidationHintsTest < Minitest::Test
6
6
  def test_version
7
- assert_equal "6.0.0", ValidationHints::VERSION
7
+ assert_equal "6.2.0", ValidationHints::VERSION
8
8
  end
9
9
 
10
10
  def test_locale_path_exists
11
- assert File.file?(ValidationHints::LOCALE_PATH),
12
- "expected locale at #{ValidationHints::LOCALE_PATH}"
11
+ assert File.file?(ValidationHints::LOCALE_PATH)
13
12
  end
14
13
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: validation_hints
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.0.0
4
+ version: 6.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ace Suares
@@ -113,6 +113,8 @@ files:
113
113
  - lib/validation_hints/railtie.rb
114
114
  - lib/validation_hints/validations_patch.rb
115
115
  - lib/validation_hints/version.rb
116
+ - test/active_model/hints_test.rb
117
+ - test/active_model/i18n_test.rb
116
118
  - test/test_helper.rb
117
119
  - test/validation_hints_test.rb
118
120
  - validation_hints.gemspec