From 23825a5dc1215aeedb38de3ba5ead052a4462973 Mon Sep 17 00:00:00 2001 From: TSMMark Date: Wed, 21 May 2025 15:13:02 -0400 Subject: [PATCH 1/7] introduce with_resource --- lib/gourami/extensions/resources.rb | 52 ++++++++++++++- lib/gourami/validations.rb | 99 +++++++++++++++++++++++------ spec/extensions/resources_spec.rb | 91 ++++++++++++++++++++++++++ 3 files changed, 221 insertions(+), 21 deletions(-) create mode 100644 spec/extensions/resources_spec.rb diff --git a/lib/gourami/extensions/resources.rb b/lib/gourami/extensions/resources.rb index 84c2e12..73ce50a 100644 --- a/lib/gourami/extensions/resources.rb +++ b/lib/gourami/extensions/resources.rb @@ -2,6 +2,56 @@ module Gourami module Extensions module Resources + # 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") + # @param 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: 0, &_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_maybe_resource_error(attribute_name, message) + if @resource_namespace + append_resource_error(@resource_namespace, @resource_uid + @offset, attribute_name, message) + else + super + end + end + # Return a deeply nested Hash which allows you to identify errors by resource. # # @return [Hash] @@ -56,7 +106,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..6656ff2 100644 --- a/lib/gourami/validations.rb +++ b/lib/gourami/validations.rb @@ -89,14 +89,43 @@ def append_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_maybe_resource_error(attribute_name, message) + append_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) + append_maybe_resource_error(attribute_name, message || :cant_be_empty) end end @@ -109,9 +138,9 @@ 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) + append_maybe_resource_error(attribute_name, message || :is_duplicated) end end @@ -137,9 +166,9 @@ 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) + append_maybe_resource_error(attribute_name, message || :is_invalid) end 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? @@ -167,11 +196,11 @@ def validate_length(attribute_name, options = {}) if min && length < min did_append_error = true - append_error(attribute_name, options.fetch(:min_message, nil) || :is_too_short) + append_maybe_resource_error(attribute_name, options.fetch(:min_message, nil) || :is_too_short) end if max && length > max did_append_error = true - append_error(attribute_name, options.fetch(:max_message, nil) || :is_too_long) + append_maybe_resource_error(attribute_name, options.fetch(:max_message, nil) || :is_too_long) end errors[attribute_name] if did_append_error @@ -185,19 +214,19 @@ 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) + append_maybe_resource_error(attribute_name, message || :isnt_listed) end end # 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") + append_maybe_resource_error(attribute_name, message || "#{obj} isn't listed") break end end @@ -208,9 +237,9 @@ 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) + append_maybe_resource_error(attribute_name, message || :cant_be_empty) end end @@ -220,9 +249,9 @@ 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) + append_maybe_resource_error(attribute_name, message || :is_invalid) end end @@ -237,14 +266,44 @@ 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 min = options.fetch(:min, nil) max = options.fetch(:max, nil) - append_error(attribute_name, options.fetch(:min_message, nil) || :less_than_min) if min && value < min - append_error(attribute_name, options.fetch(:max_message, nil) || :greater_than_max) if max && value > max + append_maybe_resource_error(attribute_name, options.fetch(:min_message, nil) || :less_than_min) if min && value < min + append_maybe_resource_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_maybe_resource_error(attribute_name, max_message || :too_many_decimal_places) + end + + if min && min > 0 && decimal_places < min + append_maybe_resource_error(attribute_name, min_message || :too_few_decimal_places) + end end end diff --git a/spec/extensions/resources_spec.rb b/spec/extensions/resources_spec.rb new file mode 100644 index 0000000..4cdb538 --- /dev/null +++ b/spec/extensions/resources_spec.rb @@ -0,0 +1,91 @@ +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: "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, + }, + ) + 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" 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, + }, + ], + ) + form.items.each_with_index do |item, index| + form.with_resource(:items, index) do + form.validate_presence(:name) + # TODO: support `append_error` on resource. + # form.append_error(:name, :is_invalid) + end + end + assert_equal(false, form.resource_has_errors?(:items, 0)) + assert_equal(false, form.resource_has_errors?(:items, 1)) + assert_equal(true, form.resource_has_errors?(:items, 2)) + assert_equal(false, form.resource_attribute_has_errors?(:items, 0, :name)) + assert_equal(false, form.resource_attribute_has_errors?(:items, 1, :name)) + assert_equal(true, form.resource_attribute_has_errors?(:items, 2, :name)) + end + end +end From e2124057e658df0982d8340d95bfa66ca7740afd Mon Sep 17 00:00:00 2001 From: TSMMark Date: Wed, 21 May 2025 15:17:18 -0400 Subject: [PATCH 2/7] append_error works inside with_record --- lib/gourami/extensions/resources.rb | 2 +- lib/gourami/validations.rb | 32 ++++++++++++++--------------- spec/extensions/resources_spec.rb | 8 ++++++-- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/lib/gourami/extensions/resources.rb b/lib/gourami/extensions/resources.rb index 73ce50a..1e57668 100644 --- a/lib/gourami/extensions/resources.rb +++ b/lib/gourami/extensions/resources.rb @@ -44,7 +44,7 @@ def current_resource # 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_maybe_resource_error(attribute_name, message) + def append_error(attribute_name, message) if @resource_namespace append_resource_error(@resource_namespace, @resource_uid + @offset, attribute_name, message) else diff --git a/lib/gourami/validations.rb b/lib/gourami/validations.rb index 6656ff2..67865d8 100644 --- a/lib/gourami/validations.rb +++ b/lib/gourami/validations.rb @@ -85,7 +85,7 @@ 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 @@ -98,8 +98,8 @@ def current_resource end # Overridden and super invoked from Extensions::Resources - def append_maybe_resource_error(attribute_name, message) - append_error(attribute_name, message) + def append_error(attribute_name, message) + append_root_error(attribute_name, message) end def get_current_resource_attribute_value(attribute_name) @@ -125,7 +125,7 @@ def get_current_resource_attribute_value(attribute_name) def validate_presence(attribute_name, message = nil) value = get_current_resource_attribute_value(attribute_name) if !value || value.to_s.strip.empty? - append_maybe_resource_error(attribute_name, message || :cant_be_empty) + append_error(attribute_name, message || :cant_be_empty) end end @@ -140,7 +140,7 @@ def validate_presence(attribute_name, message = nil) def validate_uniqueness(attribute_name, message = nil, &block) value = get_current_resource_attribute_value(attribute_name) unless block.call(value) - append_maybe_resource_error(attribute_name, message || :is_duplicated) + append_error(attribute_name, message || :is_duplicated) end end @@ -168,7 +168,7 @@ def validate_color_format(attribute_name, message = nil) def validate_format(attribute_name, format, message = nil) value = get_current_resource_attribute_value(attribute_name) if value && !(format =~ value) - append_maybe_resource_error(attribute_name, message || :is_invalid) + append_error(attribute_name, message || :is_invalid) end end @@ -196,11 +196,11 @@ def validate_length(attribute_name, options = {}) if min && length < min did_append_error = true - append_maybe_resource_error(attribute_name, options.fetch(:min_message, nil) || :is_too_short) + append_error(attribute_name, options.fetch(:min_message, nil) || :is_too_short) end if max && length > max did_append_error = true - append_maybe_resource_error(attribute_name, options.fetch(:max_message, nil) || :is_too_long) + append_error(attribute_name, options.fetch(:max_message, nil) || :is_too_long) end errors[attribute_name] if did_append_error @@ -216,7 +216,7 @@ def validate_length(attribute_name, options = {}) def validate_inclusion(attribute_name, list, message = nil) value = get_current_resource_attribute_value(attribute_name) if value && !list.include?(value) - append_maybe_resource_error(attribute_name, message || :isnt_listed) + append_error(attribute_name, message || :isnt_listed) end end @@ -226,7 +226,7 @@ def validate_inclusion_of_each(attribute_name, list, message = nil) value = get_current_resource_attribute_value(attribute_name) value && value.each do |obj| unless list.include?(obj) - append_maybe_resource_error(attribute_name, message || "#{obj} isn't listed") + append_error(attribute_name, message || "#{obj} isn't listed") break end end @@ -239,7 +239,7 @@ def validate_inclusion_of_each(attribute_name, list, message = nil) def validate_any(attribute_name, message = nil) value = get_current_resource_attribute_value(attribute_name) if value && value.empty? - append_maybe_resource_error(attribute_name, message || :cant_be_empty) + append_error(attribute_name, message || :cant_be_empty) end end @@ -251,7 +251,7 @@ def validate_any(attribute_name, message = nil) def validate_filetype(attribute_name, filetypes, message = nil) value = get_current_resource_attribute_value(attribute_name) if value && !filetypes.include?(value[:type].to_s.split("/").first) - append_maybe_resource_error(attribute_name, message || :is_invalid) + append_error(attribute_name, message || :is_invalid) end end @@ -272,8 +272,8 @@ def validate_range(attribute_name, options = {}) min = options.fetch(:min, nil) max = options.fetch(:max, nil) - append_maybe_resource_error(attribute_name, options.fetch(:min_message, nil) || :less_than_min) if min && value < min - append_maybe_resource_error(attribute_name, options.fetch(:max_message, nil) || :greater_than_max) if max && value > max + append_error(attribute_name, options.fetch(:min_message, nil) || :less_than_min) if min && value < min + 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. @@ -298,11 +298,11 @@ def validate_decimal_places(attribute_name, max:, min: 0, max_message: nil, min_ decimal_places = value.split(".", 2).last&.length || 0 if max && max > 0 && decimal_places > max - append_maybe_resource_error(attribute_name, max_message || :too_many_decimal_places) + append_error(attribute_name, max_message || :too_many_decimal_places) end if min && min > 0 && decimal_places < min - append_maybe_resource_error(attribute_name, min_message || :too_few_decimal_places) + append_error(attribute_name, min_message || :too_few_decimal_places) end end diff --git a/spec/extensions/resources_spec.rb b/spec/extensions/resources_spec.rb index 4cdb538..aa3ce06 100644 --- a/spec/extensions/resources_spec.rb +++ b/spec/extensions/resources_spec.rb @@ -76,16 +76,20 @@ form.items.each_with_index do |item, index| form.with_resource(:items, index) do form.validate_presence(:name) - # TODO: support `append_error` on resource. - # form.append_error(:name, :is_invalid) + form.append_error(:id, :is_invalid) if item["id"] > 500 end end assert_equal(false, form.resource_has_errors?(:items, 0)) assert_equal(false, form.resource_has_errors?(:items, 1)) assert_equal(true, form.resource_has_errors?(:items, 2)) + assert_equal(false, form.resource_attribute_has_errors?(:items, 0, :name)) + assert_equal(false, form.resource_attribute_has_errors?(:items, 0, :id)) assert_equal(false, form.resource_attribute_has_errors?(:items, 1, :name)) + + assert_equal(false, form.resource_attribute_has_errors?(:items, 1, :id)) assert_equal(true, form.resource_attribute_has_errors?(:items, 2, :name)) + assert_equal(true, form.resource_attribute_has_errors?(:items, 2, :id)) end end end From 495a3fc7562763af4eb6506f33abbc0d86ba5cb7 Mon Sep 17 00:00:00 2001 From: TSMMark Date: Wed, 21 May 2025 15:38:59 -0400 Subject: [PATCH 3/7] introduce with_each_resource --- lib/gourami/extensions/resources.rb | 25 +++++++++++- spec/extensions/resources_spec.rb | 60 +++++++++++++++++++++++------ 2 files changed, 73 insertions(+), 12 deletions(-) diff --git a/lib/gourami/extensions/resources.rb b/lib/gourami/extensions/resources.rb index 1e57668..d5c468e 100644 --- a/lib/gourami/extensions/resources.rb +++ b/lib/gourami/extensions/resources.rb @@ -2,11 +2,34 @@ 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 + def with_each_resource(resource_namespace, offset: 0, &_block) + send(resource_namespace).each_with_index do |resource, resource_uid| + with_resource(resource_namespace, resource_uid, offset: offset) do + yield(resource, resource_uid) + 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") - # @param 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. + # + # @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. # diff --git a/spec/extensions/resources_spec.rb b/spec/extensions/resources_spec.rb index aa3ce06..d7b9a23 100644 --- a/spec/extensions/resources_spec.rb +++ b/spec/extensions/resources_spec.rb @@ -55,7 +55,7 @@ end end - describe "#with_resource" do + describe "#with_resource(:items, index)" do it "validations within the block are scoped to the resource" do form = form_class.new( items: [ @@ -73,23 +73,61 @@ }, ], ) + offset = 3 form.items.each_with_index do |item, index| - form.with_resource(:items, index) do + 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, 0)) - assert_equal(false, form.resource_has_errors?(:items, 1)) - assert_equal(true, form.resource_has_errors?(:items, 2)) + 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, 0, :name)) - assert_equal(false, form.resource_attribute_has_errors?(:items, 0, :id)) - assert_equal(false, form.resource_attribute_has_errors?(:items, 1, :name)) + 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|" 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.with_each_resource(:items, offset: offset) do |item, index| + form.validate_presence(:name) + form.append_error(:id, :is_invalid) if item["id"] > 500 + 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, 1, :id)) - assert_equal(true, form.resource_attribute_has_errors?(:items, 2, :name)) - assert_equal(true, form.resource_attribute_has_errors?(:items, 2, :id)) + 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 end From 12c3fc3716583199a15a8769cea6b1aa17c519da Mon Sep 17 00:00:00 2001 From: TSMMark Date: Wed, 21 May 2025 15:58:53 -0400 Subject: [PATCH 4/7] with_resource for Hash --- lib/gourami/extensions/resources.rb | 15 +++- spec/extensions/resources_spec.rb | 110 +++++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 6 deletions(-) diff --git a/lib/gourami/extensions/resources.rb b/lib/gourami/extensions/resources.rb index d5c468e..72a1b94 100644 --- a/lib/gourami/extensions/resources.rb +++ b/lib/gourami/extensions/resources.rb @@ -16,7 +16,16 @@ module Resources # validate_presence(:title) # validates `attributes[:social_broadcasts][][:title]` # end # end - def with_each_resource(resource_namespace, offset: 0, &_block) + def with_each_resource(resource_namespace, offset: nil, &_block) + resources = send(resource_namespace) + if resources.is_a?(Hash) + return resources.each_with_index do |(resource_uid, resource), index| + with_resource(resource_namespace, resource_uid, offset: offset) do + yield(resource, resource_uid, index) + end + end + end + send(resource_namespace).each_with_index do |resource, resource_uid| with_resource(resource_namespace, resource_uid, offset: offset) do yield(resource, resource_uid) @@ -44,7 +53,7 @@ def with_each_resource(resource_namespace, offset: 0, &_block) # 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: 0, &_block) + def with_resource(resource_namespace, resource_uid, offset: nil, &_block) @resource_namespace = resource_namespace @resource_uid = resource_uid @offset = offset @@ -69,7 +78,7 @@ def current_resource # Otherwise, append the error to the form object. def append_error(attribute_name, message) if @resource_namespace - append_resource_error(@resource_namespace, @resource_uid + @offset, attribute_name, message) + append_resource_error(@resource_namespace, @offset ? @resource_uid + @offset : @resource_uid, attribute_name, message) else super end diff --git a/spec/extensions/resources_spec.rb b/spec/extensions/resources_spec.rb index d7b9a23..521cbda 100644 --- a/spec/extensions/resources_spec.rb +++ b/spec/extensions/resources_spec.rb @@ -6,7 +6,7 @@ form.send(:include, Gourami::Extensions::Resources) form.attribute( :items, - description: "List of objects", + description: "Array list of objects", type: :array, # element_type does not have to be so precisely defined, but it can be: element_type: { @@ -30,6 +30,32 @@ 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 @@ -55,7 +81,7 @@ end end - describe "#with_resource(:items, index)" do + describe "#with_resource(:items, index) # Array" do it "validations within the block are scoped to the resource" do form = form_class.new( items: [ @@ -80,6 +106,7 @@ 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)) @@ -94,7 +121,7 @@ end end - describe "#with_each_resource(:items) do |item, index|" do + 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: [ @@ -117,6 +144,7 @@ form.validate_presence(:name) form.append_error(:id, :is_invalid) if item["id"] > 500 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)) @@ -130,4 +158,80 @@ 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, + }, + } + ) + form.with_each_resource(:items_hash) do |item, index| + form.validate_presence(:name) + form.append_error(:id, :is_invalid) if item["id"] > 500 + 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 end From 9dae6c3a9c6ae7bc0d2d427f8199732415e9e0ff Mon Sep 17 00:00:00 2001 From: TSMMark Date: Wed, 21 May 2025 16:05:18 -0400 Subject: [PATCH 5/7] with_each_resource assert provided block params --- lib/gourami/extensions/resources.rb | 6 +- spec/extensions/resources_spec.rb | 90 ++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/lib/gourami/extensions/resources.rb b/lib/gourami/extensions/resources.rb index 72a1b94..af0d13e 100644 --- a/lib/gourami/extensions/resources.rb +++ b/lib/gourami/extensions/resources.rb @@ -26,9 +26,9 @@ def with_each_resource(resource_namespace, offset: nil, &_block) end end - send(resource_namespace).each_with_index do |resource, resource_uid| - with_resource(resource_namespace, resource_uid, offset: offset) do - yield(resource, resource_uid) + 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 diff --git a/spec/extensions/resources_spec.rb b/spec/extensions/resources_spec.rb index 521cbda..383bc45 100644 --- a/spec/extensions/resources_spec.rb +++ b/spec/extensions/resources_spec.rb @@ -140,11 +140,54 @@ ], ) offset = 3 - form.with_each_resource(:items, offset: offset) do |item, index| + + 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)) @@ -216,11 +259,54 @@ }, } ) - form.with_each_resource(:items_hash) do |item, index| + + 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")) From 0c93d0014069ca3a8c5de8d6591643ead32bebab Mon Sep 17 00:00:00 2001 From: TSMMark Date: Thu, 22 May 2025 10:52:13 -0400 Subject: [PATCH 6/7] add with_each_resource example that receives the block args --- lib/gourami/extensions/resources.rb | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/lib/gourami/extensions/resources.rb b/lib/gourami/extensions/resources.rb index af0d13e..cf706e9 100644 --- a/lib/gourami/extensions/resources.rb +++ b/lib/gourami/extensions/resources.rb @@ -16,12 +16,31 @@ module Resources # 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 |(resource_uid, resource), index| - with_resource(resource_namespace, resource_uid, offset: offset) do - yield(resource, resource_uid, index) + return resources.each_with_index do |(key, resource), index| + with_resource(resource_namespace, key, offset: offset) do + yield(resource, key, index) end end end From a2e981f867183192c37e02c47de2698c3d3fa9bb Mon Sep 17 00:00:00 2001 From: TSMMark Date: Thu, 22 May 2025 10:57:17 -0400 Subject: [PATCH 7/7] version 2.0.0 --- lib/gourami/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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