diff --git a/lib/gourami/extensions/resources.rb b/lib/gourami/extensions/resources.rb index 84c2e12..cf706e9 100644 --- a/lib/gourami/extensions/resources.rb +++ b/lib/gourami/extensions/resources.rb @@ -2,6 +2,107 @@ module Gourami module Extensions module Resources + # yield to the given block for each resource in the given namespace. + # + # @param resource_namespace [Symbol] The namespace of the resource (e.g. :users, :payments) + # + # @option offset [Integer] The offset of the resource (e.g. 0, 1, 2) for example in an update form, there may be existing items that already exist, however only the new items are sent to the form. + # + # @yield The block to execute, each time with a resource active. + # + # @example + # def validate + # with_each_resource(:social_broadcasts) do + # validate_presence(:title) # validates `attributes[:social_broadcasts][][:title]` + # end + # end + # + # @example + # def validate + # with_each_resource(:items, offset: existing_items.count) do |item, key, index| + # # Standard validation methods are available, and will validate the attributes on the resource: + # validate_decimal_places(:price, max: 2) + # + # # You may reference the resource object directly to perform custom validation logic inline: + # append_error(:password_confirmation, :doesnt_match) if item["password"] != item["password_confirmation"] + # + # # If `items` is a Hash, `key` is the hash key of the resource. + # # If `items` is an Array, `key` is the index of the resource (`+ offset`, if an offset is given). + # validate_length(:name, max: 255) if key % 2 == 0 + # + # # If `items` is a Hash, `index` is the hash key of the resource. + # # If `items` is an Array, `index` is the index of the resource (offset is NOT applied to index). + # append_error(:id, :is_invalid) if index > 500 + # end + # end + def with_each_resource(resource_namespace, offset: nil, &_block) + resources = send(resource_namespace) + if resources.is_a?(Hash) + return resources.each_with_index do |(key, resource), index| + with_resource(resource_namespace, key, offset: offset) do + yield(resource, key, index) + end + end + end + + send(resource_namespace).each_with_index do |resource, index| + with_resource(resource_namespace, index, offset: offset) do + yield(resource, offset ? index + offset : index, index) + end + end + end + + # For the duration of the given block, all validations will be done on the given resource. + # + # @param resource_namespace [Symbol] The namespace of the resource (e.g. :users, :payments) + # @param resource_uid [String|Number] The uid of the resource (e.g. 0, 1, "123") + # + # @option offset [Integer] The offset of the resource (e.g. 0, 1, 2) for example in an update form, there may be existing items that already exist, however only the new items are sent to the form. + # + # @yield The block to execute with the resource active. + # + # @example + # def validate + # validate_presence(:title) # validates `attributes[:title]` of the form + # + # with_resource(:social_broadcasts, "facebook_page-41") do + # # Within this block all validations will be done on the resource. + # validate_presence(:title) # validates `attributes[:social_broadcasts]["facebook_page-41"][:title]` + # validate_presence(:trim_start_time) # validates `attributes[:social_broadcasts]["facebook_page-41"][:trim_start_time]` + # validate_presence(:trim_end_time) # validates `attributes[:social_broadcasts]["facebook_page-41"][:trim_end_time]` + # end + # end + def with_resource(resource_namespace, resource_uid, offset: nil, &_block) + @resource_namespace = resource_namespace + @resource_uid = resource_uid + @offset = offset + yield + ensure + @resource_namespace = nil + @resource_uid = nil + @offset = nil + end + + # If a resource namespace is active (within with_resource block), find the resource using the namespace and uid. + # Otherwise, return the form object. + def current_resource + if @resource_namespace + send(@resource_namespace)[@resource_uid] + else + super + end + end + + # If a resource namespace is active (within with_resource block), append the error to the resource. + # Otherwise, append the error to the form object. + def append_error(attribute_name, message) + if @resource_namespace + append_resource_error(@resource_namespace, @offset ? @resource_uid + @offset : @resource_uid, attribute_name, message) + else + super + end + end + # Return a deeply nested Hash which allows you to identify errors by resource. # # @return [Hash] @@ -56,7 +157,7 @@ def resource_errors # TODO: YARD def resource_has_errors?(resource_namespace, resource_uid) - resource_errors[resource_namespace, resource_uid.to_s].values.map(&:flatten).any? + resource_errors[resource_namespace][resource_uid.to_s].values.map(&:flatten).any? end # TODO: YARD diff --git a/lib/gourami/validations.rb b/lib/gourami/validations.rb index a88c044..67865d8 100644 --- a/lib/gourami/validations.rb +++ b/lib/gourami/validations.rb @@ -85,16 +85,45 @@ def any_errors? # @param attribute_name [Symbol, nil] nil for base # @param error [Symbol, String] # The error identifier. - def append_error(attribute_name, error) + def append_root_error(attribute_name, error) errors[attribute_name] << error end + # NOTE: The following resource methods are to support the validate_* methods + # to cooperate with resource when using Extensions::Resources. + + # Overridden and super invoked from Extensions::Resources + def current_resource + self + end + + # Overridden and super invoked from Extensions::Resources + def append_error(attribute_name, message) + append_root_error(attribute_name, message) + end + + def get_current_resource_attribute_value(attribute_name) + resource = current_resource + # If resource responds to the attribute, return the value. Otherwise, check if it's a hash and return the value for the attribute. + if resource.respond_to?(attribute_name) + resource.send(attribute_name) + elsif resource.respond_to?(:[]) + if resource.key?(attribute_name.to_sym) + resource[attribute_name.to_sym] + elsif resource.key?(attribute_name.to_s) + resource[attribute_name.to_s] + end + else + nil + end + end + # Validate the presence of the attribute value. If the value is nil or # false append the :cant_be_empty error to the attribute. # # @param attribute_name [Symbol] def validate_presence(attribute_name, message = nil) - value = send(attribute_name) + value = get_current_resource_attribute_value(attribute_name) if !value || value.to_s.strip.empty? append_error(attribute_name, message || :cant_be_empty) end @@ -109,7 +138,7 @@ def validate_presence(attribute_name, message = nil) # A block to determine if a given value is unique or not. It receives # the value and returns true if the value is unique. def validate_uniqueness(attribute_name, message = nil, &block) - value = send(attribute_name) + value = get_current_resource_attribute_value(attribute_name) unless block.call(value) append_error(attribute_name, message || :is_duplicated) end @@ -137,7 +166,7 @@ def validate_color_format(attribute_name, message = nil) # @param attribute_name [Symbol] # @param format [Regexp] def validate_format(attribute_name, format, message = nil) - value = send(attribute_name) + value = get_current_resource_attribute_value(attribute_name) if value && !(format =~ value) append_error(attribute_name, message || :is_invalid) end @@ -157,7 +186,7 @@ def validate_length(attribute_name, options = {}) min = options.fetch(:min, nil) max = options.fetch(:max, nil) - value = send(attribute_name) + value = get_current_resource_attribute_value(attribute_name) return if options[:allow_blank] && value.blank? @@ -185,7 +214,7 @@ def validate_length(attribute_name, options = {}) # @param attribute_name [Symbol] # @param list [Array] def validate_inclusion(attribute_name, list, message = nil) - value = send(attribute_name) + value = get_current_resource_attribute_value(attribute_name) if value && !list.include?(value) append_error(attribute_name, message || :isnt_listed) end @@ -194,7 +223,7 @@ def validate_inclusion(attribute_name, list, message = nil) # Validate the presence of each object in attribute name within list. If the object # is not included in the list, append the :not_listed error to the attribute. def validate_inclusion_of_each(attribute_name, list, message = nil) - value = send(attribute_name) + value = get_current_resource_attribute_value(attribute_name) value && value.each do |obj| unless list.include?(obj) append_error(attribute_name, message || "#{obj} isn't listed") @@ -208,7 +237,7 @@ def validate_inclusion_of_each(attribute_name, list, message = nil) # # @param attribute_name [Symbol] def validate_any(attribute_name, message = nil) - value = send(attribute_name) + value = get_current_resource_attribute_value(attribute_name) if value && value.empty? append_error(attribute_name, message || :cant_be_empty) end @@ -220,7 +249,7 @@ def validate_any(attribute_name, message = nil) # @param attribute_name [Symbol] # @param filetypes [Array] def validate_filetype(attribute_name, filetypes, message = nil) - value = send(attribute_name) + value = get_current_resource_attribute_value(attribute_name) if value && !filetypes.include?(value[:type].to_s.split("/").first) append_error(attribute_name, message || :is_invalid) end @@ -237,7 +266,7 @@ def validate_filetype(attribute_name, filetypes, message = nil) # @option options [Integer] :max (nil) # The maximum value the attribute can take, if nil, no validation is made. def validate_range(attribute_name, options = {}) - value = send(attribute_name) + value = get_current_resource_attribute_value(attribute_name) return unless value @@ -247,5 +276,35 @@ def validate_range(attribute_name, options = {}) append_error(attribute_name, options.fetch(:max_message, nil) || :greater_than_max) if max && value > max end + # Ensure the provided numeric attribute has the correct number of decimal places within the given range. + # + # @param attribute_name [Symbol] + # @option options [Integer] :max (nil) + # The maximum number of decimal places the attribute can have. + # @option options [Integer] :min (0) + # The minimum number of decimal places the attribute can have. + # @option options [String] :max_message (nil) + # The error message to append if the attribute has too many decimal places. + # @option options [String] :min_message (nil) + # The error message to append if the attribute has too few decimal places. + # + # @example + # validate_decimal_places(:price, max: 2) + # validate_decimal_places(:price, min: 2, min_message: "Price must have at least 2 decimal places.") + def validate_decimal_places(attribute_name, max:, min: 0, max_message: nil, min_message: nil) + value = get_current_resource_attribute_value(attribute_name)&.to_s + return unless value + + decimal_places = value.split(".", 2).last&.length || 0 + + if max && max > 0 && decimal_places > max + append_error(attribute_name, max_message || :too_many_decimal_places) + end + + if min && min > 0 && decimal_places < min + append_error(attribute_name, min_message || :too_few_decimal_places) + end + end + end end diff --git a/lib/gourami/version.rb b/lib/gourami/version.rb index 1a97bf3..c7b6afa 100644 --- a/lib/gourami/version.rb +++ b/lib/gourami/version.rb @@ -1,3 +1,3 @@ module Gourami - VERSION = "1.4.0".freeze + VERSION = "2.0.0".freeze end diff --git a/spec/extensions/resources_spec.rb b/spec/extensions/resources_spec.rb new file mode 100644 index 0000000..383bc45 --- /dev/null +++ b/spec/extensions/resources_spec.rb @@ -0,0 +1,323 @@ +require_relative "../spec_helper" + +describe Gourami::Extensions::Resources do + let(:form_class) do + Class.new(Gourami::Form).tap do |form| + form.send(:include, Gourami::Extensions::Resources) + form.attribute( + :items, + description: "Array list of objects", + type: :array, + # element_type does not have to be so precisely defined, but it can be: + element_type: { + type: :hash, + key_type: :string, + value_type: lambda do |key, value| + case key + when *%w[id] + :integer + when *%w[name email] + :string + when *%w[is_archived] + :boolean + when *%w[amount] + :float + when *%w[created_at] + :time + else + raise "Unknown key: #{key.inspect}. (value: #{value.inspect})" + end + end, + }, + ) + form.attribute( + :items_hash, + description: "Hash list of objects", + type: :hash, + key_type: :string, + value_type: { + type: :hash, + key_type: :string, + value_type: lambda do |key, value| + case key + when *%w[id] + :integer + when *%w[name email] + :string + when *%w[is_archived] + :boolean + when *%w[amount] + :float + when *%w[created_at] + :time + else + raise "Unknown key: #{key.inspect}. (value: #{value.inspect})" + end + end, + }, + ) + end + end + + describe "#append_resource_error and #resource_errors" do + it "#append_resource_error adds errors to the resource and #resource_errors returns them" do + form = form_class.new + + assert_equal(false, form.any_resource_errors?) + assert_equal(false, form.any_errors?) + + assert_equal(false, form.resource_has_errors?(:items, 0)) + assert_equal(false, form.resource_attribute_has_errors?(:items, 0, :name)) + assert_equal(false, form.resource_attribute_has_errors?(:items, 0, :id)) + + form.append_resource_error(:items, 0, :name, :is_invalid) + + assert_equal(true, form.any_resource_errors?) + assert_equal(true, form.any_errors?) + + assert_equal(true, form.resource_has_errors?(:items, 0)) + assert_equal(true, form.resource_attribute_has_errors?(:items, 0, :name)) + assert_equal(false, form.resource_attribute_has_errors?(:items, 0, :id)) + end + end + + describe "#with_resource(:items, index) # Array" do + it "validations within the block are scoped to the resource" do + form = form_class.new( + items: [ + { + name: "Sean", + id: 123, + }, + { + name: "Leigh", + id: 456, + }, + { + name: "", + id: 789, + }, + ], + ) + offset = 3 + form.items.each_with_index do |item, index| + form.with_resource(:items, index, offset: offset) do + form.validate_presence(:name) + form.append_error(:id, :is_invalid) if item["id"] > 500 + end + end + + assert_equal(false, form.resource_has_errors?(:items, offset + 0)) + assert_equal(false, form.resource_has_errors?(:items, offset + 1)) + assert_equal(true, form.resource_has_errors?(:items, offset + 2)) + + assert_equal(false, form.resource_attribute_has_errors?(:items, offset + 0, :name)) + assert_equal(false, form.resource_attribute_has_errors?(:items, offset + 0, :id)) + assert_equal(false, form.resource_attribute_has_errors?(:items, offset + 1, :name)) + + assert_equal(false, form.resource_attribute_has_errors?(:items, offset + 1, :id)) + assert_equal(true, form.resource_attribute_has_errors?(:items, offset + 2, :name)) + assert_equal(true, form.resource_attribute_has_errors?(:items, offset + 2, :id)) + end + end + + describe "#with_each_resource(:items) do |item, index| # Array" do + it "validations within the block are scoped to the resource" do + form = form_class.new( + items: [ + { + name: "Sean", + id: 123, + }, + { + name: "Leigh", + id: 456, + }, + { + name: "", + id: 789, + }, + ], + ) + offset = 3 + + received_items = [] + received_keys = [] + received_indexes = [] + form.with_each_resource(:items, offset: offset) do |item, key, index| + received_items << item + received_keys << key + received_indexes << index + + form.validate_presence(:name) + form.append_error(:id, :is_invalid) if item["id"] > 500 + end + + assert_equal( + [ + { + "name" => "Sean", + "id" => 123, + }, + { + "name" => "Leigh", + "id" => 456, + }, + { + "name" => "", + "id" => 789, + }, + ], + received_items + ) + assert_equal( + [ + offset + 0, + offset + 1, + offset + 2, + ], + received_keys + ) + + assert_equal( + [ + 0, + 1, + 2, + ], + received_indexes + ) + + assert_equal(false, form.resource_has_errors?(:items, offset + 0)) + assert_equal(false, form.resource_has_errors?(:items, offset + 1)) + assert_equal(true, form.resource_has_errors?(:items, offset + 2)) + + assert_equal(false, form.resource_attribute_has_errors?(:items, offset + 0, :name)) + assert_equal(false, form.resource_attribute_has_errors?(:items, offset + 0, :id)) + assert_equal(false, form.resource_attribute_has_errors?(:items, offset + 1, :name)) + + assert_equal(false, form.resource_attribute_has_errors?(:items, offset + 1, :id)) + assert_equal(true, form.resource_attribute_has_errors?(:items, offset + 2, :name)) + assert_equal(true, form.resource_attribute_has_errors?(:items, offset + 2, :id)) + end + end + + describe "#with_resource(:items, index) # Hash" do + it "validations within the block are scoped to the resource" do + form = form_class.new( + items_hash: { + "abc" => { + name: "Sean", + id: 123, + }, + "def" => { + name: "Leigh", + id: 456, + }, + "ghi" => { + name: "", + id: 789, + }, + }, + ) + form.items_hash.each do |key, item| + form.with_resource(:items_hash, key) do + form.validate_presence(:name) + form.append_error(:id, :is_invalid) if item["id"] > 500 + end + end + + assert_equal(false, form.resource_has_errors?(:items_hash, "abc")) + assert_equal(false, form.resource_has_errors?(:items_hash, "def")) + assert_equal(true, form.resource_has_errors?(:items_hash, "ghi")) + + assert_equal(false, form.resource_attribute_has_errors?(:items_hash, "abc", :name)) + assert_equal(false, form.resource_attribute_has_errors?(:items_hash, "abc", :id)) + assert_equal(false, form.resource_attribute_has_errors?(:items_hash, "def", :name)) + + assert_equal(false, form.resource_attribute_has_errors?(:items_hash, "def", :id)) + assert_equal(true, form.resource_attribute_has_errors?(:items_hash, "ghi", :name)) + assert_equal(true, form.resource_attribute_has_errors?(:items_hash, "ghi", :id)) + end + end + + describe "#with_each_resource(:items) do |item, index| # Hash" do + it "validations within the block are scoped to the resource" do + form = form_class.new( + items_hash: { + "abc" => { + name: "Sean", + id: 123, + }, + "def" => { + name: "Leigh", + id: 456, + }, + "ghi" => { + name: "", + id: 789, + }, + } + ) + + received_items = [] + received_keys = [] + received_indexes = [] + form.with_each_resource(:items_hash) do |item, key, index| + received_items << item + received_keys << key + received_indexes << index + + form.validate_presence(:name) + form.append_error(:id, :is_invalid) if item["id"] > 500 + end + + assert_equal( + [ + { + "name" => "Sean", + "id" => 123, + }, + { + "name" => "Leigh", + "id" => 456, + }, + { + "name" => "", + "id" => 789, + }, + ], + received_items + ) + assert_equal( + [ + "abc", + "def", + "ghi", + ], + received_keys + ) + + assert_equal( + [ + 0, + 1, + 2, + ], + received_indexes + ) + + assert_equal(false, form.resource_has_errors?(:items_hash, "abc")) + assert_equal(false, form.resource_has_errors?(:items_hash, "def")) + assert_equal(true, form.resource_has_errors?(:items_hash, "ghi")) + + assert_equal(false, form.resource_attribute_has_errors?(:items_hash, "abc", :name)) + assert_equal(false, form.resource_attribute_has_errors?(:items_hash, "abc", :id)) + assert_equal(false, form.resource_attribute_has_errors?(:items_hash, "def", :name)) + + assert_equal(false, form.resource_attribute_has_errors?(:items_hash, "def", :id)) + assert_equal(true, form.resource_attribute_has_errors?(:items_hash, "ghi", :name)) + assert_equal(true, form.resource_attribute_has_errors?(:items_hash, "ghi", :id)) + end + end +end