require 'active_support/core_ext/string/inflections' module ActiveModel class Errors include Enumerable CALLBACKS_OPTIONS = [:if, :unless, :on, :allow_nil, :allow_blank, :strict] MESSAGE_OPTIONS = [:message] attr_reader :messages, :details # Pass in the instance of the object that is using the errors object. # # class Person # def initialize # @errors = ActiveModel::Errors.new(self) # end # end def initialize(base = {}) @base = base @messages = apply_default_array({}) @details = apply_default_array({}) reactive_empty! true end # When passed a symbol or a name of a method, returns an array of errors # for the method. # # person.errors[:name] # => ["cannot be nil"] # person.errors['name'] # => ["cannot be nil"] def [](attribute) messages[attribute] end # Iterates through each error key, value pair in the error messages hash. # Yields the attribute and the error for that attribute. If the attribute # has more than one error message, yields once for each error message. # # person.errors.add(:name, :blank, message: "can't be blank") # person.errors.each do |attribute, error| # # Will yield :name and "can't be blank" # end # # person.errors.add(:name, :not_specified, message: "must be specified") # person.errors.each do |attribute, error| # # Will yield :name and "can't be blank" # # then yield :name and "must be specified" # end def each messages.each_key do |attribute| messages[attribute].each { |error| yield attribute, error } end end # Delete messages for +key+. Returns the deleted messages. # # person.errors[:name] # => ["cannot be nil"] # person.errors.delete(:name) # => ["cannot be nil"] # person.errors[:name] # => [] def delete(attribute) details.delete(attribute) messages.delete(attribute) end # Returns the number of error messages. # # person.errors.add(:name, :blank, message: "can't be blank") # person.errors.size # => 1 # person.errors.add(:name, :not_specified, message: "must be specified") # person.errors.size # => 2 def size values.flatten.size end alias :count :size # Returns all message keys. # # person.errors.messages # => {:name=>["cannot be nil", "must be specified"]} # person.errors.keys # => [:name] def keys messages.select do |key, value| !value.empty? end.keys end # Returns all message values. # # person.errors.messages # => {:name=>["cannot be nil", "must be specified"]} # person.errors.values # => [["cannot be nil", "must be specified"]] def values messages.select do |key, value| !value.empty? end.values end # NOTE: Doesn't use i18n # # Returns a full message for a given attribute. # # person.errors.full_message(:name, 'is invalid') # => "Name is invalid" def full_message(attribute, message) return message if attribute == :base # TODO: When opal_activesupport 0.3.2 is released, use `humanize` # attr_name = attribute.to_s.tr('.', '_').humanize attr_name = attribute.to_s.tr('.', '_').tr('_', ' ').gsub(/_id$/, '').capitalize # attr_name = @base.class.human_attribute_name(attribute, default: attr_name) # if @base.class.respond_to?(:human_attribute_name) attr_name = @base.class.human_attribute_name(attribute, default: attr_name) # end # I18n.t(:"errors.format", # default: "%{attribute} %{message}", # attribute: attr_name, # message: message) "#{attr_name} #{message}" end # Returns all the full error messages in an array. # # class Person # validates_presence_of :name, :address, :email # validates_length_of :name, in: 5..30 # end # # person = Person.create(address: '123 First St.') # person.errors.full_messages # # => ["Name is too short (minimum is 5 characters)", "Name can't be blank", "Email can't be blank"] def full_messages map { |attribute, message| full_message(attribute, message) } end alias :to_a :full_messages # Returns all the full error messages for a given attribute in an array. # # class Person # validates_presence_of :name, :email # validates_length_of :name, in: 5..30 # end # # person = Person.create() # person.errors.full_messages_for(:name) # # => ["Name is too short (minimum is 5 characters)", "Name can't be blank"] def full_messages_for(attribute) messages[attribute].map { |message| full_message(attribute, message) } end # Returns a Hash of attributes with their error messages. If +full_messages+ # is +true+, it will contain full messages (see +full_message+). # # person.errors.to_hash # => {:name=>["cannot be nil"]} # person.errors.to_hash(true) # => {:name=>["name cannot be nil"]} def to_hash(full_messages = false) if full_messages messages.each_with_object({}) do |(attribute, array), messages| messages[attribute] = array.map { |message| full_message(attribute, message) } end else without_default_proc(messages) end end # Returns a Hash that can be used as the JSON representation for this # object. You can pass the :full_messages option. This determines # if the json object should contain full messages or not (false by default). # # person.errors.as_json # => {:name=>["cannot be nil"]} # person.errors.as_json(full_messages: true) # => {:name=>["name cannot be nil"]} def as_json(options = nil) to_hash(options && options[:full_messages]) end # NOTE: Doesn't actually do any of the below i18n lookups # # Translates an error message in its default scope # (activemodel.errors.messages). # # Error messages are first looked up in activemodel.errors.models.MODEL.attributes.ATTRIBUTE.MESSAGE, # if it's not there, it's looked up in activemodel.errors.models.MODEL.MESSAGE and if # that is not there also, it returns the translation of the default message # (e.g. activemodel.errors.messages.MESSAGE). The translated model # name, translated attribute name and the value are available for # interpolation. # # When using inheritance in your models, it will check all the inherited # models too, but only if the model itself hasn't been found. Say you have # class Admin < User; end and you wanted the translation for # the :blank error message for the title attribute, # it looks for these translations: # # * activemodel.errors.models.admin.attributes.title.blank # * activemodel.errors.models.admin.blank # * activemodel.errors.models.user.attributes.title.blank # * activemodel.errors.models.user.blank # * any default you provided through the +options+ hash (in the activemodel.errors scope) # * activemodel.errors.messages.blank # * errors.attributes.title.blank # * errors.messages.blank def generate_message(attribute, type = :invalid, options = {}) options.delete(:message) || type end # Returns +true+ if no errors are found, +false+ otherwise. # If the error message is a string it can be empty. # # person.errors.full_messages # => ["name cannot be nil"] # person.errors.empty? # => false def empty? size.zero? end alias :blank? :empty? def reactive_empty? React::State.get_state(self, 'ERRORS?') end # Clear the error messages. # # person.errors.full_messages # => ["name cannot be nil"] # person.errors.clear # person.errors.full_messages # => [] def clear messages.clear details.clear.tap { reactive_empty! true } end # Merges the errors from other. # # other - The ActiveModel::Errors instance. # # Examples # # person.errors.merge!(other) def merge!(other) @messages.merge!(other.messages) { |_, ary1, ary2| ary1 + ary2 } @details.merge!(other.details) { |_, ary1, ary2| ary1 + ary2 }.tap { reactive_empty! } end # Returns +true+ if the error messages include an error for the given key # +attribute+, +false+ otherwise. # # person.errors.messages # => {:name=>["cannot be nil"]} # person.errors.include?(:name) # => true # person.errors.include?(:age) # => false def include?(attribute) attribute = attribute.to_sym messages.key?(attribute) && messages[attribute].present? end alias :has_key? :include? alias :key? :include? # NOTE: strict option isn't ported yet # # Adds +message+ to the error messages and used validator type to +details+ on +attribute+. # More than one error can be added to the same +attribute+. # If no +message+ is supplied, :invalid is assumed. # # person.errors.add(:name) # # => ["is invalid"] # person.errors.add(:name, :not_implemented, message: "must be implemented") # # => ["is invalid", "must be implemented"] # # person.errors.messages # # => {:name=>["is invalid", "must be implemented"]} # # person.errors.details # # => {:name=>[{error: :not_implemented}, {error: :invalid}]} # # If +message+ is a symbol, it will be translated using the appropriate # scope (see +generate_message+). # # If +message+ is a proc, it will be called, allowing for things like # Time.now to be used within an error. # # If the :strict option is set to +true+, it will raise # ActiveModel::StrictValidationFailed instead of adding the error. # :strict option can also be set to any other exception. # # person.errors.add(:name, :invalid, strict: true) # # => ActiveModel::StrictValidationFailed: Name is invalid # person.errors.add(:name, :invalid, strict: NameIsInvalid) # # => NameIsInvalid: Name is invalid # # person.errors.messages # => {} # # +attribute+ should be set to :base if the error is not # directly associated with a single attribute. # # person.errors.add(:base, :name_or_email_blank, # message: "either name or email must be present") # person.errors.messages # # => {:base=>["either name or email must be present"]} # person.errors.details # # => {:base=>[{error: :name_or_email_blank}]} def add(attribute, message = :invalid, options = {}) message = message.call if message.respond_to?(:call) detail = normalize_detail(message, options) message = normalize_message(attribute, message, options) # if exception = options[:strict] # exception = ActiveModel::StrictValidationFailed if exception == true # raise exception, full_message(attribute, message) # end details[attribute.to_sym] << detail (messages[attribute.to_sym] << message).tap { reactive_empty! false } end # NOTE: Due to Opal not supporting Symbol this isn't identical, # but probably still works fine in most cases. # # Returns +true+ if an error on the attribute with the given message is # present, or +false+ otherwise. +message+ is treated the same as for +add+. # # person.errors.add :name, :blank # person.errors.added? :name, :blank # => true # person.errors.added? :name, "can't be blank" # => true # # If the error message requires an option, then it returns +true+ with # the correct option, or +false+ with an incorrect or missing option. # # person.errors.add :name, :too_long, { count: 25 } # person.errors.added? :name, :too_long, count: 25 # => true # person.errors.added? :name, "is too long (maximum is 25 characters)" # => true # person.errors.added? :name, :too_long, count: 24 # => false # person.errors.added? :name, :too_long # => false # person.errors.added? :name, "is too long" # => false def added?(attribute, message = :invalid, options = {}) # if message.is_a? Symbol # self.details[attribute].map { |e| e[:error] }.include? message # else # message = message.call if message.respond_to?(:call) # message = normalize_message(attribute, message, options) # self[attribute].include? message # end return true if details[attribute].map { |e| e[:error] }.include? message message = message.call if message.respond_to?(:call) message = normalize_message(attribute, message, options) self[attribute].include? message end private def apply_default_array(hash) hash.default_proc = proc { |h, key| h[key] = [] } hash end def normalize_message(attribute, message, options) # case message # when Symbol # generate_message(attribute, message, options.except(*CALLBACKS_OPTIONS)) # else # message # end generate_message( attribute, message, options.reject { |k, _| CALLBACKS_OPTIONS.include?(k) } ) end def normalize_detail(message, options) # { error: message }.merge(options.except(*CALLBACKS_OPTIONS + MESSAGE_OPTIONS)) ignore = (CALLBACKS_OPTIONS + MESSAGE_OPTIONS) { error: message }.merge(options.reject { |k, _| ignore.include?(k) }) end def without_default_proc(hash) hash.dup.tap do |new_h| new_h.default_proc = nil end end def reactive_empty!(state = empty?) React::State.set_state(self, 'ERRORS?', state) unless ReactiveRecord::Base.data_loading? end end end