From e1f0bced3753c13435ef7595e799b3a3d33b5cfe Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Mon, 23 Mar 2020 16:11:49 -0700 Subject: [PATCH 01/35] Sketch out API for rendering collections Co-Authored-By: Joel Hawksley --- lib/view_component/base.rb | 19 +++++++++++++++++++ test/app/components/product_component.htm.erb | 4 ++++ test/app/components/product_component.rb | 8 ++++++++ test/view_component/view_component_test.rb | 14 ++++++++++++++ 4 files changed, 45 insertions(+) create mode 100644 test/app/components/product_component.htm.erb create mode 100644 test/app/components/product_component.rb diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index bd8f23f1d..f38f91226 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -5,6 +5,20 @@ require "view_component/previewable" module ViewComponent + class CollectionBase + def initialize(component, opts) + @component = component + @opts = opts + end + + def render_in(view_context, &block) + @opts[:items].map do |item| + # TODO: handle as: for kw_arg name and any additiona args (e.g., extra:) + @component.new(product: item, extra: "").render_in(view_context, &block) + end.join + end + end + class Base < ActionView::Base include ActiveSupport::Configurable include ViewComponent::Previewable @@ -14,6 +28,11 @@ class Base < ActionView::Base class_attribute :content_areas, default: [] self.content_areas = [] # default doesn't work until Rails 5.2 + + def self.collection(opts) + CollectionBase.new(self, opts) + end + # Entrypoint for rendering components. # # view_context: ActionView context from calling view diff --git a/test/app/components/product_component.htm.erb b/test/app/components/product_component.htm.erb new file mode 100644 index 000000000..e86ec499c --- /dev/null +++ b/test/app/components/product_component.htm.erb @@ -0,0 +1,4 @@ +

+ <%= @product.title %> + <%= @extra %> +

\ No newline at end of file diff --git a/test/app/components/product_component.rb b/test/app/components/product_component.rb new file mode 100644 index 000000000..63cf78560 --- /dev/null +++ b/test/app/components/product_component.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class ProductComponent < ViewComponent::Base + def initialize(product:, extra:) + @product = product + @extra = extra + end +end \ No newline at end of file diff --git a/test/view_component/view_component_test.rb b/test/view_component/view_component_test.rb index 2c379f7c2..69afe58e6 100644 --- a/test/view_component/view_component_test.rb +++ b/test/view_component/view_component_test.rb @@ -338,6 +338,20 @@ def test_validations_component assert_equal exception.message, "Validation failed: Content can't be blank" end + # Some example usages: + # + # <%= render(ProductComponent.collection(@products)) %> + # <%= render(ProductComponent.collection(items: @products, as: :product, foo: foo)) %> + # <%= render(ProductComponent.new(product: @product)) %> + + def test_render_collection + @products = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] + render_inline(ProductComponent.collection(items: @products, as: :product, extra: "extra")) + + assert_selector("h1", count: 2) + end + + private def modify_file(file, content) From c8997b1a8c6cbc81c9cc7587c796d7126431cfdd Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Mon, 23 Mar 2020 16:13:41 -0700 Subject: [PATCH 02/35] Final newlines are nice --- test/app/components/product_component.htm.erb | 2 +- test/app/components/product_component.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/app/components/product_component.htm.erb b/test/app/components/product_component.htm.erb index e86ec499c..9fb83c44e 100644 --- a/test/app/components/product_component.htm.erb +++ b/test/app/components/product_component.htm.erb @@ -1,4 +1,4 @@

<%= @product.title %> <%= @extra %> -

\ No newline at end of file + diff --git a/test/app/components/product_component.rb b/test/app/components/product_component.rb index 63cf78560..f14132fe3 100644 --- a/test/app/components/product_component.rb +++ b/test/app/components/product_component.rb @@ -5,4 +5,4 @@ def initialize(product:, extra:) @product = product @extra = extra end -end \ No newline at end of file +end From 698fa7da2fdecba605e55e19a50a571b684d513f Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Tue, 24 Mar 2020 07:20:08 -0700 Subject: [PATCH 03/35] Rename template --- .../{product_component.htm.erb => product_component.html.erb} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/app/components/{product_component.htm.erb => product_component.html.erb} (100%) diff --git a/test/app/components/product_component.htm.erb b/test/app/components/product_component.html.erb similarity index 100% rename from test/app/components/product_component.htm.erb rename to test/app/components/product_component.html.erb From 0fe965f16f905af84f18619e047ce2e9839968c2 Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Tue, 24 Mar 2020 07:20:55 -0700 Subject: [PATCH 04/35] Handle :as, fill out test coverage --- lib/view_component/base.rb | 35 +++++++++++++++++----- test/view_component/view_component_test.rb | 20 +++++++++++++ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index f38f91226..b738fe4db 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -6,17 +6,38 @@ module ViewComponent class CollectionBase - def initialize(component, opts) + def initialize(component, options) @component = component - @opts = opts + @options = options end def render_in(view_context, &block) - @opts[:items].map do |item| - # TODO: handle as: for kw_arg name and any additiona args (e.g., extra:) - @component.new(product: item, extra: "").render_in(view_context, &block) + as = as_variable(@options) || :item + args = @options.except(:items, :as) + + @options[:items].map do |item| + args[as] = item + @component.new(args).render_in(view_context, &block) end.join end + + private + + # Pulled from rails... maybe inherit from ActionView? (but these methods are private so...) + def as_variable(options) + if as = options[:as] + raise_invalid_option_as(as) unless /\A[a-z_]\w*\z/.match?(as.to_s) + as.to_sym + end + end + + OPTION_AS_ERROR_MESSAGE = "The value (%s) of the option `as` is not a valid Ruby identifier; " \ + "make sure it starts with lowercase letter, " \ + "and is followed by any combination of letters, numbers and underscores." + + def raise_invalid_option_as(as) + raise ArgumentError.new(OPTION_AS_ERROR_MESSAGE % (as)) + end end class Base < ActionView::Base @@ -29,8 +50,8 @@ class Base < ActionView::Base self.content_areas = [] # default doesn't work until Rails 5.2 - def self.collection(opts) - CollectionBase.new(self, opts) + def self.collection(options) + CollectionBase.new(self, options) end # Entrypoint for rendering components. diff --git a/test/view_component/view_component_test.rb b/test/view_component/view_component_test.rb index 69afe58e6..d0bcffa8c 100644 --- a/test/view_component/view_component_test.rb +++ b/test/view_component/view_component_test.rb @@ -351,6 +351,26 @@ def test_render_collection assert_selector("h1", count: 2) end + def test_render_collection_invalid_as + @products = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] + exception = assert_raises ArgumentError do + render_inline(ProductComponent.collection(items: @products, as: "Product")) + end + + assert_equal exception.message, "The value (Product) of the option `as` is not a valid Ruby identifier; make sure it starts with lowercase letter, and is followed by any combination of letters, numbers and underscores." + end + + # TODO: Requires new + # def test_render_collection_without_as + # end + + def test_render_single_item_from_collection + @product = OpenStruct.new(title: "Bye") + render_inline(ProductComponent.new(product: @product, extra: "abc")) + + assert_selector("h1", count: 1) + end + private From 7aaef37664e1e3be2396a324a3d32a691e228dda Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Tue, 24 Mar 2020 07:26:23 -0700 Subject: [PATCH 05/35] Test out not using :as --- test/app/components/product_item_component.html.erb | 4 ++++ test/app/components/product_item_component.rb | 8 ++++++++ test/view_component/view_component_test.rb | 10 ++++++---- 3 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 test/app/components/product_item_component.html.erb create mode 100644 test/app/components/product_item_component.rb diff --git a/test/app/components/product_item_component.html.erb b/test/app/components/product_item_component.html.erb new file mode 100644 index 000000000..0049766a4 --- /dev/null +++ b/test/app/components/product_item_component.html.erb @@ -0,0 +1,4 @@ +

+ <%= @product.title %> + <%= @extra %> +

diff --git a/test/app/components/product_item_component.rb b/test/app/components/product_item_component.rb new file mode 100644 index 000000000..68199a969 --- /dev/null +++ b/test/app/components/product_item_component.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class ProductItemComponent < ViewComponent::Base + def initialize(item:, extra:) + @product = item + @extra = extra + end +end diff --git a/test/view_component/view_component_test.rb b/test/view_component/view_component_test.rb index d0bcffa8c..ae5823eb6 100644 --- a/test/view_component/view_component_test.rb +++ b/test/view_component/view_component_test.rb @@ -360,9 +360,12 @@ def test_render_collection_invalid_as assert_equal exception.message, "The value (Product) of the option `as` is not a valid Ruby identifier; make sure it starts with lowercase letter, and is followed by any combination of letters, numbers and underscores." end - # TODO: Requires new - # def test_render_collection_without_as - # end + def test_render_collection_without_as + @products = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] + render_inline(ProductItemComponent.collection(items: @products, extra: "extra")) + + assert_selector("h2", count: 2) + end def test_render_single_item_from_collection @product = OpenStruct.new(title: "Bye") @@ -371,7 +374,6 @@ def test_render_single_item_from_collection assert_selector("h1", count: 1) end - private def modify_file(file, content) From 6df4749cdc8f3fb4ec9cb0c15268bd441a8e28b9 Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Tue, 24 Mar 2020 09:43:44 -0700 Subject: [PATCH 06/35] Name the ivar item for clarity --- test/app/components/product_item_component.html.erb | 2 +- test/app/components/product_item_component.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/app/components/product_item_component.html.erb b/test/app/components/product_item_component.html.erb index 0049766a4..0bc09a647 100644 --- a/test/app/components/product_item_component.html.erb +++ b/test/app/components/product_item_component.html.erb @@ -1,4 +1,4 @@

- <%= @product.title %> + <%= @item.title %> <%= @extra %>

diff --git a/test/app/components/product_item_component.rb b/test/app/components/product_item_component.rb index 68199a969..cfa3fefd6 100644 --- a/test/app/components/product_item_component.rb +++ b/test/app/components/product_item_component.rb @@ -2,7 +2,7 @@ class ProductItemComponent < ViewComponent::Base def initialize(item:, extra:) - @product = item + @item = item @extra = extra end end From 6750a3ac84c72e1d12fdcc3b5b4e9b4f65e9ccc6 Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Wed, 25 Mar 2020 12:25:09 -0700 Subject: [PATCH 07/35] Change up the api to .all(collection: ...) --- lib/view_component/base.rb | 12 ++++++------ test/view_component/view_component_test.rb | 13 ++++++++----- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index b738fe4db..7732e31e5 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -5,7 +5,7 @@ require "view_component/previewable" module ViewComponent - class CollectionBase + class Collection def initialize(component, options) @component = component @options = options @@ -13,9 +13,9 @@ def initialize(component, options) def render_in(view_context, &block) as = as_variable(@options) || :item - args = @options.except(:items, :as) + args = @options.except(:collection, :as) - @options[:items].map do |item| + @options[:collection].map do |item| args[as] = item @component.new(args).render_in(view_context, &block) end.join @@ -49,9 +49,9 @@ class Base < ActionView::Base class_attribute :content_areas, default: [] self.content_areas = [] # default doesn't work until Rails 5.2 - - def self.collection(options) - CollectionBase.new(self, options) + # Render a component collection. + def self.all(options) + Collection.new(self, options) end # Entrypoint for rendering components. diff --git a/test/view_component/view_component_test.rb b/test/view_component/view_component_test.rb index 141b03c07..474f9abcc 100644 --- a/test/view_component/view_component_test.rb +++ b/test/view_component/view_component_test.rb @@ -388,13 +388,16 @@ def test_backtrace_returns_correct_file_and_line_number # Some example usages: # - # <%= render(ProductComponent.collection(@products)) %> - # <%= render(ProductComponent.collection(items: @products, as: :product, foo: foo)) %> + # TODO: requires knowing the iterator name. + # <%= render(ProductComponent.all(@products)) %> + # + # Done + # <%= render(ProductComponent.all(collection: @products, as: :product, foo: foo)) %> # <%= render(ProductComponent.new(product: @product)) %> def test_render_collection @products = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] - render_inline(ProductComponent.collection(items: @products, as: :product, extra: "extra")) + render_inline(ProductComponent.all(collection: @products, as: :product, extra: "extra")) assert_selector("h1", count: 2) end @@ -402,7 +405,7 @@ def test_render_collection def test_render_collection_invalid_as @products = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] exception = assert_raises ArgumentError do - render_inline(ProductComponent.collection(items: @products, as: "Product")) + render_inline(ProductComponent.all(collection: @products, as: "Product")) end assert_equal exception.message, "The value (Product) of the option `as` is not a valid Ruby identifier; make sure it starts with lowercase letter, and is followed by any combination of letters, numbers and underscores." @@ -410,7 +413,7 @@ def test_render_collection_invalid_as def test_render_collection_without_as @products = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] - render_inline(ProductItemComponent.collection(items: @products, extra: "extra")) + render_inline(ProductItemComponent.all(collection: @products, extra: "extra")) assert_selector("h2", count: 2) end From 6d962566232a44de8e77f5821ec24ca670c2e42e Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Wed, 25 Mar 2020 12:25:34 -0700 Subject: [PATCH 08/35] Move default kw to as_variable --- lib/view_component/base.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 7732e31e5..c981e1e5e 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -12,7 +12,7 @@ def initialize(component, options) end def render_in(view_context, &block) - as = as_variable(@options) || :item + as = as_variable(@options) args = @options.except(:collection, :as) @options[:collection].map do |item| @@ -28,6 +28,8 @@ def as_variable(options) if as = options[:as] raise_invalid_option_as(as) unless /\A[a-z_]\w*\z/.match?(as.to_s) as.to_sym + else + :item end end From 6a3e54c5dc2a538d374bd163ce04a67df8c92587 Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Wed, 25 Mar 2020 13:09:57 -0700 Subject: [PATCH 09/35] Auto generate item name --- lib/view_component/base.rb | 14 +++++++++----- .../components/product_coupon_component.html.erb | 1 + test/app/components/product_coupon_component.rb | 7 +++++++ .../app/components/product_item_component.html.erb | 2 +- test/app/components/product_item_component.rb | 4 ++-- test/view_component/view_component_test.rb | 14 ++++++++++++++ 6 files changed, 34 insertions(+), 8 deletions(-) create mode 100644 test/app/components/product_coupon_component.html.erb create mode 100644 test/app/components/product_coupon_component.rb diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index c981e1e5e..9a1e5e58d 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -12,7 +12,7 @@ def initialize(component, options) end def render_in(view_context, &block) - as = as_variable(@options) + as = as_variable(@component.virtual_path, @options) args = @options.except(:collection, :as) @options[:collection].map do |item| @@ -23,13 +23,13 @@ def render_in(view_context, &block) private - # Pulled from rails... maybe inherit from ActionView? (but these methods are private so...) - def as_variable(options) + # Copied from https://github.com/rails/rails/blob/e2cf0b1d780b2e09f5270249ca021d94ce4fff9d/actionview/lib/action_view/renderer/partial_renderer.rb + def as_variable(path, options) if as = options[:as] raise_invalid_option_as(as) unless /\A[a-z_]\w*\z/.match?(as.to_s) as.to_sym else - :item + File.basename(path).gsub(/_component/,"").to_sym end end @@ -134,7 +134,11 @@ def helpers # Removes the first part of the path and the extension. def virtual_path - self.class.source_location.gsub(%r{(.*app/components)|(\.rb)}, "") + self.class.virtual_path + end + + def self.virtual_path + source_location.gsub(%r{(.*app/components)|(\.rb)}, "") end def view_cache_dependencies diff --git a/test/app/components/product_coupon_component.html.erb b/test/app/components/product_coupon_component.html.erb new file mode 100644 index 000000000..27e81f2dd --- /dev/null +++ b/test/app/components/product_coupon_component.html.erb @@ -0,0 +1 @@ +

<%= @item.title %>

diff --git a/test/app/components/product_coupon_component.rb b/test/app/components/product_coupon_component.rb new file mode 100644 index 000000000..a5cda75e8 --- /dev/null +++ b/test/app/components/product_coupon_component.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ProductCouponComponent < ViewComponent::Base + def initialize(item:) + @item = item + end +end diff --git a/test/app/components/product_item_component.html.erb b/test/app/components/product_item_component.html.erb index 0bc09a647..507a57080 100644 --- a/test/app/components/product_item_component.html.erb +++ b/test/app/components/product_item_component.html.erb @@ -1,4 +1,4 @@

- <%= @item.title %> + <%= @product_item.title %> <%= @extra %>

diff --git a/test/app/components/product_item_component.rb b/test/app/components/product_item_component.rb index cfa3fefd6..2cb8e6fa9 100644 --- a/test/app/components/product_item_component.rb +++ b/test/app/components/product_item_component.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true class ProductItemComponent < ViewComponent::Base - def initialize(item:, extra:) - @item = item + def initialize(product_item:, extra:) + @product_item = product_item @extra = extra end end diff --git a/test/view_component/view_component_test.rb b/test/view_component/view_component_test.rb index 474f9abcc..39b167a5d 100644 --- a/test/view_component/view_component_test.rb +++ b/test/view_component/view_component_test.rb @@ -396,12 +396,26 @@ def test_backtrace_returns_correct_file_and_line_number # <%= render(ProductComponent.new(product: @product)) %> def test_render_collection + @products = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] + render_inline(ProductComponent.all(collection: @products, extra: "extra")) + + assert_selector("h1", count: 2) + end + + def test_render_collection_specify_as @products = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] render_inline(ProductComponent.all(collection: @products, as: :product, extra: "extra")) assert_selector("h1", count: 2) end + def test_render_collection_custom_as + @coupons = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] + render_inline(ProductCouponComponent.all(collection: @coupons, as: :item)) + + assert_selector("h3", count: 2) + end + def test_render_collection_invalid_as @products = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] exception = assert_raises ArgumentError do From 492c7118481ec3b7f324ba612693c552efdb6de5 Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Wed, 25 Mar 2020 13:44:25 -0700 Subject: [PATCH 10/35] Handle passing collection object directly --- lib/view_component/base.rb | 21 ++++++++-- test/view_component/view_component_test.rb | 45 +++++++++++++++++----- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 9a1e5e58d..bb8a2a144 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -6,16 +6,18 @@ module ViewComponent class Collection - def initialize(component, options) + def initialize(component, object = nil, options) @component = component + @object = object @options = options end def render_in(view_context, &block) as = as_variable(@component.virtual_path, @options) + collection = collection_variable(@object, @options) args = @options.except(:collection, :as) - @options[:collection].map do |item| + collection.map do |item| args[as] = item @component.new(args).render_in(view_context, &block) end.join @@ -23,6 +25,17 @@ def render_in(view_context, &block) private + def collection_variable(object, options) + if object.respond_to?(:to_ary) + object.to_ary + elsif options.key?(:collection) + collection = options[:collection] + collection ? collection.to_a : [] + else + raise ArgumentError.new("Must specify the option `collection` or pass a valid collection object.") + end + end + # Copied from https://github.com/rails/rails/blob/e2cf0b1d780b2e09f5270249ca021d94ce4fff9d/actionview/lib/action_view/renderer/partial_renderer.rb def as_variable(path, options) if as = options[:as] @@ -52,8 +65,8 @@ class Base < ActionView::Base self.content_areas = [] # default doesn't work until Rails 5.2 # Render a component collection. - def self.all(options) - Collection.new(self, options) + def self.all(*args) + Collection.new(self, *args) end # Entrypoint for rendering components. diff --git a/test/view_component/view_component_test.rb b/test/view_component/view_component_test.rb index 39b167a5d..3c0ceeacf 100644 --- a/test/view_component/view_component_test.rb +++ b/test/view_component/view_component_test.rb @@ -388,10 +388,7 @@ def test_backtrace_returns_correct_file_and_line_number # Some example usages: # - # TODO: requires knowing the iterator name. # <%= render(ProductComponent.all(@products)) %> - # - # Done # <%= render(ProductComponent.all(collection: @products, as: :product, foo: foo)) %> # <%= render(ProductComponent.new(product: @product)) %> @@ -402,6 +399,33 @@ def test_render_collection assert_selector("h1", count: 2) end + def test_render_collection_minimal + @products = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] + render_inline(ProductComponent.all(@products, extra: "extra")) + + assert_selector("h1", count: 2) + end + + def test_render_collection_minimal_specify_as + @products = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] + render_inline(ProductComponent.all(@products, as: :product, extra: "extra")) + + assert_selector("h1", count: 2) + end + + def test_render_collection_object_and_collection_key + @products = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] + + render_inline(ProductComponent.all(@products, collection: [], extra: "extra")) + assert_selector("h1", count: 2) + + render_inline(ProductComponent.all([], collection: @products, extra: "extra")) + refute_selector("h1") + + render_inline(ProductComponent.all(nil, collection: @products, extra: "extra")) + assert_selector("h1", count: 2) + end + def test_render_collection_specify_as @products = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] render_inline(ProductComponent.all(collection: @products, as: :product, extra: "extra")) @@ -416,20 +440,21 @@ def test_render_collection_custom_as assert_selector("h3", count: 2) end - def test_render_collection_invalid_as - @products = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] + def test_render_collection_invalid_collection exception = assert_raises ArgumentError do - render_inline(ProductComponent.all(collection: @products, as: "Product")) + render_inline(ProductCouponComponent.all(as: :item)) end - assert_equal exception.message, "The value (Product) of the option `as` is not a valid Ruby identifier; make sure it starts with lowercase letter, and is followed by any combination of letters, numbers and underscores." + assert_equal exception.message, "Must specify the option `collection` or pass a valid collection object." end - def test_render_collection_without_as + def test_render_collection_invalid_as @products = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] - render_inline(ProductItemComponent.all(collection: @products, extra: "extra")) + exception = assert_raises ArgumentError do + render_inline(ProductComponent.all(collection: @products, as: "Product")) + end - assert_selector("h2", count: 2) + assert_equal exception.message, "The value (Product) of the option `as` is not a valid Ruby identifier; make sure it starts with lowercase letter, and is followed by any combination of letters, numbers and underscores." end def test_render_single_item_from_collection From 85806b4b67183342e82bcccab76b7c613c5d9648 Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Wed, 25 Mar 2020 13:44:44 -0700 Subject: [PATCH 11/35] Duplicative of ProductCoupon --- test/app/components/product_item_component.html.erb | 4 ---- test/app/components/product_item_component.rb | 8 -------- 2 files changed, 12 deletions(-) delete mode 100644 test/app/components/product_item_component.html.erb delete mode 100644 test/app/components/product_item_component.rb diff --git a/test/app/components/product_item_component.html.erb b/test/app/components/product_item_component.html.erb deleted file mode 100644 index 507a57080..000000000 --- a/test/app/components/product_item_component.html.erb +++ /dev/null @@ -1,4 +0,0 @@ -

- <%= @product_item.title %> - <%= @extra %> -

diff --git a/test/app/components/product_item_component.rb b/test/app/components/product_item_component.rb deleted file mode 100644 index 2cb8e6fa9..000000000 --- a/test/app/components/product_item_component.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -class ProductItemComponent < ViewComponent::Base - def initialize(product_item:, extra:) - @product_item = product_item - @extra = extra - end -end From a41527b47ae611790882d32902697ea35d06f278 Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Wed, 25 Mar 2020 13:48:28 -0700 Subject: [PATCH 12/35] Just change the listing order --- lib/view_component/base.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index bb8a2a144..d6b5abbba 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -146,14 +146,14 @@ def helpers end # Removes the first part of the path and the extension. - def virtual_path - self.class.virtual_path - end - def self.virtual_path source_location.gsub(%r{(.*app/components)|(\.rb)}, "") end + def virtual_path + self.class.virtual_path + end + def view_cache_dependencies [] end From 4b7e367dd7d17d8044683280440a9179a442e24f Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Wed, 25 Mar 2020 13:49:32 -0700 Subject: [PATCH 13/35] Move ViewComponent:Collection to a dedicated file --- lib/view_component/base.rb | 51 +----------------------------- lib/view_component/collection.rb | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 50 deletions(-) create mode 100644 lib/view_component/collection.rb diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index d6b5abbba..b146a8887 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -2,59 +2,10 @@ require "action_view" require "active_support/configurable" +require "view_component/collection" require "view_component/previewable" module ViewComponent - class Collection - def initialize(component, object = nil, options) - @component = component - @object = object - @options = options - end - - def render_in(view_context, &block) - as = as_variable(@component.virtual_path, @options) - collection = collection_variable(@object, @options) - args = @options.except(:collection, :as) - - collection.map do |item| - args[as] = item - @component.new(args).render_in(view_context, &block) - end.join - end - - private - - def collection_variable(object, options) - if object.respond_to?(:to_ary) - object.to_ary - elsif options.key?(:collection) - collection = options[:collection] - collection ? collection.to_a : [] - else - raise ArgumentError.new("Must specify the option `collection` or pass a valid collection object.") - end - end - - # Copied from https://github.com/rails/rails/blob/e2cf0b1d780b2e09f5270249ca021d94ce4fff9d/actionview/lib/action_view/renderer/partial_renderer.rb - def as_variable(path, options) - if as = options[:as] - raise_invalid_option_as(as) unless /\A[a-z_]\w*\z/.match?(as.to_s) - as.to_sym - else - File.basename(path).gsub(/_component/,"").to_sym - end - end - - OPTION_AS_ERROR_MESSAGE = "The value (%s) of the option `as` is not a valid Ruby identifier; " \ - "make sure it starts with lowercase letter, " \ - "and is followed by any combination of letters, numbers and underscores." - - def raise_invalid_option_as(as) - raise ArgumentError.new(OPTION_AS_ERROR_MESSAGE % (as)) - end - end - class Base < ActionView::Base include ActiveSupport::Configurable include ViewComponent::Previewable diff --git a/lib/view_component/collection.rb b/lib/view_component/collection.rb new file mode 100644 index 000000000..e5884f89a --- /dev/null +++ b/lib/view_component/collection.rb @@ -0,0 +1,54 @@ + +# frozen_string_literal: true + +module ViewComponent + class Collection + def initialize(component, object = nil, options) + @component = component + @object = object + @options = options + end + + def render_in(view_context, &block) + as = as_variable(@component.virtual_path, @options) + collection = collection_variable(@object, @options) + args = @options.except(:collection, :as) + + collection.map do |item| + args[as] = item + @component.new(args).render_in(view_context, &block) + end.join + end + + private + + def collection_variable(object, options) + if object.respond_to?(:to_ary) + object.to_ary + elsif options.key?(:collection) + collection = options[:collection] + collection ? collection.to_a : [] + else + raise ArgumentError.new("Must specify the option `collection` or pass a valid collection object.") + end + end + + # Copied from https://github.com/rails/rails/blob/e2cf0b1d780b2e09f5270249ca021d94ce4fff9d/actionview/lib/action_view/renderer/partial_renderer.rb + def as_variable(path, options) + if as = options[:as] + raise_invalid_option_as(as) unless /\A[a-z_]\w*\z/.match?(as.to_s) + as.to_sym + else + File.basename(path).gsub(/_component/,"").to_sym + end + end + + OPTION_AS_ERROR_MESSAGE = "The value (%s) of the option `as` is not a valid Ruby identifier; " \ + "make sure it starts with lowercase letter, " \ + "and is followed by any combination of letters, numbers and underscores." + + def raise_invalid_option_as(as) + raise ArgumentError.new(OPTION_AS_ERROR_MESSAGE % (as)) + end + end +end From c872eaeb04d3c63317102468cd552f24f8847f5a Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Wed, 25 Mar 2020 13:57:08 -0700 Subject: [PATCH 14/35] Draft a changelog entry --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33014c2d8..ca22fdcd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # master +# v2.1.0 + +* Support rendering collection (e.g., `render(MyComponent.all(@items))`). + + *Tim Clem, Joel Hawksley* + # v2.0.0 * Move to `ViewComponent` namespace, removing all references to `ActionView`. From 22559677780806e34f764a7b18a26b01ba50c10d Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Wed, 25 Mar 2020 13:57:24 -0700 Subject: [PATCH 15/35] Add myself to the readme and tidy contributors --- README.md | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f53951c12..01f0d1bda 100644 --- a/README.md +++ b/README.md @@ -446,15 +446,10 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/github |@mellowfish|@horacio|@dukex|@dark-panda|@smashwilson| |Spring Hill, TN|Buenos Aires|São Paulo||Gambrills, MD| -|blakewilliams| -|:---:| -|@blakewilliams| -|Boston, MA| - -|seanpdoyle| -|:---:| -|@seanpdoyle| -|New York, NY| +|blakewilliams|seanpdoyle|tclem| +|:---:|:---:|:---:| +|@blakewilliams|@seanpdoyle|@tclem| +|Boston, MA|New York, NY|San Francisco, CA| ## License From 606d568c0ed0f8dd43d66156adc734d550b696f2 Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Wed, 25 Mar 2020 13:58:47 -0700 Subject: [PATCH 16/35] Add space after comma --- lib/view_component/collection.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/view_component/collection.rb b/lib/view_component/collection.rb index e5884f89a..a9c930c1d 100644 --- a/lib/view_component/collection.rb +++ b/lib/view_component/collection.rb @@ -39,7 +39,7 @@ def as_variable(path, options) raise_invalid_option_as(as) unless /\A[a-z_]\w*\z/.match?(as.to_s) as.to_sym else - File.basename(path).gsub(/_component/,"").to_sym + File.basename(path).gsub(/_component/, "").to_sym end end From 9baecd111058443f59eb085954d14d1bf5f8b4f5 Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Thu, 26 Mar 2020 07:59:09 -0700 Subject: [PATCH 17/35] Use the component class name directly --- lib/view_component/base.rb | 6 +----- lib/view_component/collection.rb | 6 +++--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index b146a8887..8a582889c 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -97,12 +97,8 @@ def helpers end # Removes the first part of the path and the extension. - def self.virtual_path - source_location.gsub(%r{(.*app/components)|(\.rb)}, "") - end - def virtual_path - self.class.virtual_path + self.class.source_location.gsub(%r{(.*app/components)|(\.rb)}, "") end def view_cache_dependencies diff --git a/lib/view_component/collection.rb b/lib/view_component/collection.rb index a9c930c1d..639472163 100644 --- a/lib/view_component/collection.rb +++ b/lib/view_component/collection.rb @@ -10,7 +10,7 @@ def initialize(component, object = nil, options) end def render_in(view_context, &block) - as = as_variable(@component.virtual_path, @options) + as = as_variable(@component, @options) collection = collection_variable(@object, @options) args = @options.except(:collection, :as) @@ -34,12 +34,12 @@ def collection_variable(object, options) end # Copied from https://github.com/rails/rails/blob/e2cf0b1d780b2e09f5270249ca021d94ce4fff9d/actionview/lib/action_view/renderer/partial_renderer.rb - def as_variable(path, options) + def as_variable(component, options) if as = options[:as] raise_invalid_option_as(as) unless /\A[a-z_]\w*\z/.match?(as.to_s) as.to_sym else - File.basename(path).gsub(/_component/, "").to_sym + component.name.demodulize.underscore.chomp("_component").to_sym end end From 556de9e8d39de3b10d2bc097175c8709cab99174 Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Thu, 26 Mar 2020 08:05:39 -0700 Subject: [PATCH 18/35] More specific asserts for extra arguments --- test/app/components/product_component.html.erb | 6 +++--- test/view_component/view_component_test.rb | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/test/app/components/product_component.html.erb b/test/app/components/product_component.html.erb index 9fb83c44e..d8b161cc7 100644 --- a/test/app/components/product_component.html.erb +++ b/test/app/components/product_component.html.erb @@ -1,4 +1,4 @@ -

- <%= @product.title %> +

<%= @product.title %>

+

<%= @extra %> - +

diff --git a/test/view_component/view_component_test.rb b/test/view_component/view_component_test.rb index 3c0ceeacf..376acb239 100644 --- a/test/view_component/view_component_test.rb +++ b/test/view_component/view_component_test.rb @@ -403,7 +403,9 @@ def test_render_collection_minimal @products = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] render_inline(ProductComponent.all(@products, extra: "extra")) - assert_selector("h1", count: 2) + assert_selector("h1", text: "Hi") + assert_selector("h1", text: "Bye") + assert_selector("p", text: "extra", count: 2) end def test_render_collection_minimal_specify_as From f897365109453c196b99e8ce9ecebac1819d0d3d Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Thu, 26 Mar 2020 08:27:59 -0700 Subject: [PATCH 19/35] ViewComponent::Collection constructor can be private --- lib/view_component/collection.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/view_component/collection.rb b/lib/view_component/collection.rb index 639472163..63f441e75 100644 --- a/lib/view_component/collection.rb +++ b/lib/view_component/collection.rb @@ -3,12 +3,6 @@ module ViewComponent class Collection - def initialize(component, object = nil, options) - @component = component - @object = object - @options = options - end - def render_in(view_context, &block) as = as_variable(@component, @options) collection = collection_variable(@object, @options) @@ -22,6 +16,12 @@ def render_in(view_context, &block) private + def initialize(component, object = nil, options) + @component = component + @object = object + @options = options + end + def collection_variable(object, options) if object.respond_to?(:to_ary) object.to_ary From 3793dd3532c1287f626ab1c956033b388fde47f3 Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Thu, 26 Mar 2020 08:37:21 -0700 Subject: [PATCH 20/35] Be defensive against component changing the args hash --- lib/view_component/collection.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/view_component/collection.rb b/lib/view_component/collection.rb index 63f441e75..3094e215a 100644 --- a/lib/view_component/collection.rb +++ b/lib/view_component/collection.rb @@ -9,8 +9,7 @@ def render_in(view_context, &block) args = @options.except(:collection, :as) collection.map do |item| - args[as] = item - @component.new(args).render_in(view_context, &block) + @component.new(args.merge(as => item)).render_in(view_context, &block) end.join end From f294f9948fbaaa5e944310a19487261b25eb7cda Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Thu, 26 Mar 2020 09:41:36 -0700 Subject: [PATCH 21/35] s/all/with_collection --- lib/view_component/base.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 8a582889c..6126f5649 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -16,7 +16,7 @@ class Base < ActionView::Base self.content_areas = [] # default doesn't work until Rails 5.2 # Render a component collection. - def self.all(*args) + def self.with_collection(*args) Collection.new(self, *args) end From 44f326dae25e0cfee2a365f33641c7ea6092530d Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Thu, 26 Mar 2020 09:42:37 -0700 Subject: [PATCH 22/35] Iterate on the api Must pass collection object directly, no more special keyword args --- lib/view_component/base.rb | 9 ++++++++ lib/view_component/collection.rb | 37 ++++++-------------------------- 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 6126f5649..a92dffe06 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -219,6 +219,15 @@ def with_content_areas(*areas) self.content_areas = areas end + # Support overriding this component's collection parameter name + def with_collection_parameter(param) + @with_collection_parameter = param + end + + def collection_parameter_name + (@with_collection_parameter || name.demodulize.underscore.chomp("_component")).to_sym + end + private def matching_views_in_source_location diff --git a/lib/view_component/collection.rb b/lib/view_component/collection.rb index 3094e215a..dad4a68d2 100644 --- a/lib/view_component/collection.rb +++ b/lib/view_component/collection.rb @@ -4,50 +4,27 @@ module ViewComponent class Collection def render_in(view_context, &block) - as = as_variable(@component, @options) - collection = collection_variable(@object, @options) - args = @options.except(:collection, :as) + as = @component.collection_parameter_name - collection.map do |item| - @component.new(args.merge(as => item)).render_in(view_context, &block) + @collection.map do |item| + @component.new(@options.merge(as => item)).render_in(view_context, &block) end.join end private - def initialize(component, object = nil, options) + def initialize(component, object, **options) @component = component - @object = object + @collection = collection_variable(object || []) @options = options end - def collection_variable(object, options) + def collection_variable(object) if object.respond_to?(:to_ary) object.to_ary - elsif options.key?(:collection) - collection = options[:collection] - collection ? collection.to_a : [] else - raise ArgumentError.new("Must specify the option `collection` or pass a valid collection object.") + raise ArgumentError.new("The value of the argument isn't a valid collection. Make sure it responds to to_ary: #{object.inspect}") end end - - # Copied from https://github.com/rails/rails/blob/e2cf0b1d780b2e09f5270249ca021d94ce4fff9d/actionview/lib/action_view/renderer/partial_renderer.rb - def as_variable(component, options) - if as = options[:as] - raise_invalid_option_as(as) unless /\A[a-z_]\w*\z/.match?(as.to_s) - as.to_sym - else - component.name.demodulize.underscore.chomp("_component").to_sym - end - end - - OPTION_AS_ERROR_MESSAGE = "The value (%s) of the option `as` is not a valid Ruby identifier; " \ - "make sure it starts with lowercase letter, " \ - "and is followed by any combination of letters, numbers and underscores." - - def raise_invalid_option_as(as) - raise ArgumentError.new(OPTION_AS_ERROR_MESSAGE % (as)) - end end end From 9f1bc8a3205db3e2b42b092f07650515612c7199 Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Thu, 26 Mar 2020 09:45:05 -0700 Subject: [PATCH 23/35] Bring tests up-to-date --- .../app/components/product_component.html.erb | 5 +- test/app/components/product_component.rb | 4 +- .../product_coupon_component.html.erb | 2 +- .../components/product_coupon_component.rb | 2 + test/view_component/view_component_test.rb | 90 +++++++------------ 5 files changed, 40 insertions(+), 63 deletions(-) diff --git a/test/app/components/product_component.html.erb b/test/app/components/product_component.html.erb index d8b161cc7..c0060724d 100644 --- a/test/app/components/product_component.html.erb +++ b/test/app/components/product_component.html.erb @@ -1,4 +1,5 @@ -

<%= @product.title %>

+

Product

+

<%= @product.name %>

- <%= @extra %> + <%= @notice %>

diff --git a/test/app/components/product_component.rb b/test/app/components/product_component.rb index f14132fe3..12baa3661 100644 --- a/test/app/components/product_component.rb +++ b/test/app/components/product_component.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true class ProductComponent < ViewComponent::Base - def initialize(product:, extra:) + def initialize(product:, notice:) @product = product - @extra = extra + @notice = notice end end diff --git a/test/app/components/product_coupon_component.html.erb b/test/app/components/product_coupon_component.html.erb index 27e81f2dd..b25a06ede 100644 --- a/test/app/components/product_coupon_component.html.erb +++ b/test/app/components/product_coupon_component.html.erb @@ -1 +1 @@ -

<%= @item.title %>

+

<%= @item.percent_off %>%

diff --git a/test/app/components/product_coupon_component.rb b/test/app/components/product_coupon_component.rb index a5cda75e8..a93694265 100644 --- a/test/app/components/product_coupon_component.rb +++ b/test/app/components/product_coupon_component.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ProductCouponComponent < ViewComponent::Base + with_collection_parameter :item + def initialize(item:) @item = item end diff --git a/test/view_component/view_component_test.rb b/test/view_component/view_component_test.rb index 376acb239..d14459712 100644 --- a/test/view_component/view_component_test.rb +++ b/test/view_component/view_component_test.rb @@ -99,7 +99,7 @@ def test_renders_content_for_template end def test_renders_content_areas_template_with_initialize_arguments - render_inline(ContentAreasComponent.new(title: "Hi!", footer: "Bye!")) do |component| + render_inline(ContentAreasComponent.new(title: "Radio clock!", footer: "Bye!")) do |component| component.with(:body) { "Have a nice day." } end end @@ -129,11 +129,11 @@ def test_renders_content_areas_template_with_block def test_renders_content_areas_template_replaces_content render_inline(ContentAreasComponent.new(footer: "Bye!")) do |component| component.with(:title) { "Hello!" } - component.with(:title, "Hi!") + component.with(:title, "Radio clock!") component.with(:body) { "Have a nice day." } end - assert_selector(".title", text: "Hi!") + assert_selector(".title", text: "Radio clock!") assert_selector(".body", text: "Have a nice day.") assert_selector(".footer", text: "Bye!") end @@ -386,84 +386,58 @@ def test_backtrace_returns_correct_file_and_line_number assert_match %r[app/components/exception_in_template_component\.html\.erb:2], error.backtrace[0] end - # Some example usages: - # - # <%= render(ProductComponent.all(@products)) %> - # <%= render(ProductComponent.all(collection: @products, as: :product, foo: foo)) %> - # <%= render(ProductComponent.new(product: @product)) %> + # Rendering collections def test_render_collection - @products = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] - render_inline(ProductComponent.all(collection: @products, extra: "extra")) + @products = [OpenStruct.new(name: "Radio clock"), OpenStruct.new(name: "Mints")] + render_inline(ProductComponent.with_collection(@products, notice: "On sale")) - assert_selector("h1", count: 2) + assert_selector("h1", text: "Product", count: 2) + assert_selector("h2", text: "Radio clock") + assert_selector("h2", text: "Mints") + assert_selector("p", text: "On sale", count: 2) end - def test_render_collection_minimal - @products = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] - render_inline(ProductComponent.all(@products, extra: "extra")) + def test_render_collection_custom_collection_parameter_name + @coupons = [OpenStruct.new(percent_off: 20), OpenStruct.new(percent_off: 50)] + render_inline(ProductCouponComponent.with_collection(@coupons)) - assert_selector("h1", text: "Hi") - assert_selector("h1", text: "Bye") - assert_selector("p", text: "extra", count: 2) + assert_selector("h3", text: "20%") + assert_selector("h3", text: "50%") end - def test_render_collection_minimal_specify_as - @products = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] - render_inline(ProductComponent.all(@products, as: :product, extra: "extra")) + def test_render_collection_nil_and_empty_collection + [nil, []].each do |collection| + render_inline(ProductComponent.with_collection(collection, notice: "On sale")) - assert_selector("h1", count: 2) - end - - def test_render_collection_object_and_collection_key - @products = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] - - render_inline(ProductComponent.all(@products, collection: [], extra: "extra")) - assert_selector("h1", count: 2) - - render_inline(ProductComponent.all([], collection: @products, extra: "extra")) - refute_selector("h1") - - render_inline(ProductComponent.all(nil, collection: @products, extra: "extra")) - assert_selector("h1", count: 2) - end - - def test_render_collection_specify_as - @products = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] - render_inline(ProductComponent.all(collection: @products, as: :product, extra: "extra")) - - assert_selector("h1", count: 2) - end - - def test_render_collection_custom_as - @coupons = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] - render_inline(ProductCouponComponent.all(collection: @coupons, as: :item)) - - assert_selector("h3", count: 2) + assert_no_text("Products") + end end - def test_render_collection_invalid_collection + def test_render_collection_missing_collection_object exception = assert_raises ArgumentError do - render_inline(ProductCouponComponent.all(as: :item)) + render_inline(ProductComponent.with_collection(notice: "On sale")) end - assert_equal exception.message, "Must specify the option `collection` or pass a valid collection object." + assert_equal exception.message, "The value of the argument isn't a valid collection. Make sure it responds to to_ary: {:notice=>\"On sale\"}" end - def test_render_collection_invalid_as - @products = [OpenStruct.new(title: "Hi"), OpenStruct.new(title: "Bye")] + def test_render_collection_missing_arg + @products = [OpenStruct.new(name: "Radio clock"), OpenStruct.new(name: "Mints")] exception = assert_raises ArgumentError do - render_inline(ProductComponent.all(collection: @products, as: "Product")) + render_inline(ProductComponent.with_collection(@products)) end - assert_equal exception.message, "The value (Product) of the option `as` is not a valid Ruby identifier; make sure it starts with lowercase letter, and is followed by any combination of letters, numbers and underscores." + assert_equal exception.message, "missing keyword: notice" end def test_render_single_item_from_collection - @product = OpenStruct.new(title: "Bye") - render_inline(ProductComponent.new(product: @product, extra: "abc")) + @product = OpenStruct.new(name: "Mints") + render_inline(ProductComponent.new(product: @product, notice: "On sale")) - assert_selector("h1", count: 1) + assert_selector("h1", text: "Product", count: 1) + assert_selector("h2", text: "Mints") + assert_selector("p", text: "On sale", count: 1) end private From 84a6495683355231a41f673cdec0888a5ca3d73a Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Thu, 26 Mar 2020 09:56:12 -0700 Subject: [PATCH 24/35] Express these as list items --- test/app/components/product_component.html.erb | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/test/app/components/product_component.html.erb b/test/app/components/product_component.html.erb index c0060724d..51c11d789 100644 --- a/test/app/components/product_component.html.erb +++ b/test/app/components/product_component.html.erb @@ -1,5 +1,7 @@ -

Product

-

<%= @product.name %>

-

- <%= @notice %> -

+
  • +

    Product

    +

    <%= @product.name %>

    +

    + <%= @notice %> +

    +
  • From 6c3e7ee9f333200f706d2dafcd247397f8d9ead8 Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Thu, 26 Mar 2020 09:56:26 -0700 Subject: [PATCH 25/35] Wire up an integration test --- .../app/controllers/integration_examples_controller.rb | 4 ++++ test/app/views/integration_examples/products.html.erb | 4 ++++ test/config/routes.rb | 1 + test/view_component/integration_test.rb | 10 ++++++++++ 4 files changed, 19 insertions(+) create mode 100644 test/app/views/integration_examples/products.html.erb diff --git a/test/app/controllers/integration_examples_controller.rb b/test/app/controllers/integration_examples_controller.rb index f03dc880a..891b7062e 100644 --- a/test/app/controllers/integration_examples_controller.rb +++ b/test/app/controllers/integration_examples_controller.rb @@ -14,4 +14,8 @@ def controller_inline def controller_inline_baseline render("integration_examples/_controller_inline", locals: { message: "bar" }) end + + def products + @products = [OpenStruct.new(name: "Radio clock"), OpenStruct.new(name: "Mints")] + end end diff --git a/test/app/views/integration_examples/products.html.erb b/test/app/views/integration_examples/products.html.erb new file mode 100644 index 000000000..512177efb --- /dev/null +++ b/test/app/views/integration_examples/products.html.erb @@ -0,0 +1,4 @@ +

    Products for sale

    +
      + <%= render(ProductComponent.with_collection(@products, notice: "Today only")) %> +
    diff --git a/test/config/routes.rb b/test/config/routes.rb index c07cfc7d8..aa17fa2ff 100644 --- a/test/config/routes.rb +++ b/test/config/routes.rb @@ -6,6 +6,7 @@ get :partial, to: "integration_examples#partial" get :content, to: "integration_examples#content" get :variants, to: "integration_examples#variants" + get :products, to: "integration_examples#products" get :cached, to: "integration_examples#cached" get :render_check, to: "integration_examples#render_check" get :controller_inline, to: "integration_examples#controller_inline" diff --git a/test/view_component/integration_test.rb b/test/view_component/integration_test.rb index 424ccb2ff..6921c93da 100644 --- a/test/view_component/integration_test.rb +++ b/test/view_component/integration_test.rb @@ -175,4 +175,14 @@ class IntegrationTest < ActionDispatch::IntegrationTest assert_select("div", "hello,world!") end + + test "renders collections" do + get "/products" + + assert_select("h1", text: "Products for sale") + assert_select("h1", text: "Product", count: 2) + assert_select("h2", text: "Radio clock") + assert_select("h2", text: "Mints") + assert_select("p", text: "Today only", count: 2) + end end From 7196befdaf43147b10fb0892bb8ec7e3fc49fa48 Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Thu, 26 Mar 2020 09:56:51 -0700 Subject: [PATCH 26/35] html_safe is required for rendered content to show up --- lib/view_component/collection.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/view_component/collection.rb b/lib/view_component/collection.rb index dad4a68d2..029122bcf 100644 --- a/lib/view_component/collection.rb +++ b/lib/view_component/collection.rb @@ -8,7 +8,7 @@ def render_in(view_context, &block) @collection.map do |item| @component.new(@options.merge(as => item)).render_in(view_context, &block) - end.join + end.join.html_safe end private From 1b186fed96b3b0c9c16ea2690d8fb55c7cf2b166 Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Thu, 26 Mar 2020 10:10:25 -0700 Subject: [PATCH 27/35] Different versions of ruby show slightly different errors here --- test/view_component/view_component_test.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/view_component/view_component_test.rb b/test/view_component/view_component_test.rb index d14459712..0743d926b 100644 --- a/test/view_component/view_component_test.rb +++ b/test/view_component/view_component_test.rb @@ -428,7 +428,8 @@ def test_render_collection_missing_arg render_inline(ProductComponent.with_collection(@products)) end - assert_equal exception.message, "missing keyword: notice" + assert_match /missing keyword/, exception.message + assert_match /notice/, exception.message end def test_render_single_item_from_collection From 340122b7e99ce1788221a86f38512213c4f48fdf Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Thu, 26 Mar 2020 11:03:41 -0700 Subject: [PATCH 28/35] Update api in changelog entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca22fdcd5..3ad673b9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ # v2.1.0 -* Support rendering collection (e.g., `render(MyComponent.all(@items))`). +* Support rendering collections (e.g., `render(MyComponent.with_collection(@items))`). *Tim Clem, Joel Hawksley* From 4364c69ce7dfddda64cb0ce1f78185e1af542ae4 Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Thu, 26 Mar 2020 11:05:32 -0700 Subject: [PATCH 29/35] Bump version to 2.1.0 --- Gemfile.lock | 2 +- lib/view_component/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index b0fefb766..39f703580 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - view_component (2.0.0) + view_component (2.1.0) capybara (~> 3) GEM diff --git a/lib/view_component/version.rb b/lib/view_component/version.rb index 631284d34..5a0fe8949 100644 --- a/lib/view_component/version.rb +++ b/lib/view_component/version.rb @@ -3,7 +3,7 @@ module ViewComponent module VERSION MAJOR = 2 - MINOR = 0 + MINOR = 1 PATCH = 0 STRING = [MAJOR, MINOR, PATCH].join(".") From 6612f8bbb05d40d5412e1adaea58af72db8471a8 Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Thu, 26 Mar 2020 14:09:17 -0700 Subject: [PATCH 30/35] No need to use ivars in these tests --- test/view_component/view_component_test.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/view_component/view_component_test.rb b/test/view_component/view_component_test.rb index 0743d926b..13ca215c2 100644 --- a/test/view_component/view_component_test.rb +++ b/test/view_component/view_component_test.rb @@ -389,8 +389,8 @@ def test_backtrace_returns_correct_file_and_line_number # Rendering collections def test_render_collection - @products = [OpenStruct.new(name: "Radio clock"), OpenStruct.new(name: "Mints")] - render_inline(ProductComponent.with_collection(@products, notice: "On sale")) + products = [OpenStruct.new(name: "Radio clock"), OpenStruct.new(name: "Mints")] + render_inline(ProductComponent.with_collection(products, notice: "On sale")) assert_selector("h1", text: "Product", count: 2) assert_selector("h2", text: "Radio clock") @@ -399,8 +399,8 @@ def test_render_collection end def test_render_collection_custom_collection_parameter_name - @coupons = [OpenStruct.new(percent_off: 20), OpenStruct.new(percent_off: 50)] - render_inline(ProductCouponComponent.with_collection(@coupons)) + coupons = [OpenStruct.new(percent_off: 20), OpenStruct.new(percent_off: 50)] + render_inline(ProductCouponComponent.with_collection(coupons)) assert_selector("h3", text: "20%") assert_selector("h3", text: "50%") @@ -423,9 +423,9 @@ def test_render_collection_missing_collection_object end def test_render_collection_missing_arg - @products = [OpenStruct.new(name: "Radio clock"), OpenStruct.new(name: "Mints")] + products = [OpenStruct.new(name: "Radio clock"), OpenStruct.new(name: "Mints")] exception = assert_raises ArgumentError do - render_inline(ProductComponent.with_collection(@products)) + render_inline(ProductComponent.with_collection(products)) end assert_match /missing keyword/, exception.message @@ -433,8 +433,8 @@ def test_render_collection_missing_arg end def test_render_single_item_from_collection - @product = OpenStruct.new(name: "Mints") - render_inline(ProductComponent.new(product: @product, notice: "On sale")) + product = OpenStruct.new(name: "Mints") + render_inline(ProductComponent.new(product: product, notice: "On sale")) assert_selector("h1", text: "Product", count: 1) assert_selector("h2", text: "Mints") From 5a13abe5981cb5cadcc9040a697c281c06aaff57 Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Thu, 26 Mar 2020 15:53:45 -0700 Subject: [PATCH 31/35] Update test/view_component/view_component_test.rb Co-Authored-By: Joel Hawksley --- test/view_component/view_component_test.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/test/view_component/view_component_test.rb b/test/view_component/view_component_test.rb index 13ca215c2..35e3d47bf 100644 --- a/test/view_component/view_component_test.rb +++ b/test/view_component/view_component_test.rb @@ -386,7 +386,6 @@ def test_backtrace_returns_correct_file_and_line_number assert_match %r[app/components/exception_in_template_component\.html\.erb:2], error.backtrace[0] end - # Rendering collections def test_render_collection products = [OpenStruct.new(name: "Radio clock"), OpenStruct.new(name: "Mints")] From a1b85fc13e93239d0d64c4fa823a6166410ec267 Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Thu, 26 Mar 2020 16:06:49 -0700 Subject: [PATCH 32/35] Revert "Bump version to 2.1.0" This reverts commit 4364c69ce7dfddda64cb0ce1f78185e1af542ae4. --- Gemfile.lock | 2 +- lib/view_component/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 39f703580..b0fefb766 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - view_component (2.1.0) + view_component (2.0.0) capybara (~> 3) GEM diff --git a/lib/view_component/version.rb b/lib/view_component/version.rb index 5a0fe8949..631284d34 100644 --- a/lib/view_component/version.rb +++ b/lib/view_component/version.rb @@ -3,7 +3,7 @@ module ViewComponent module VERSION MAJOR = 2 - MINOR = 1 + MINOR = 0 PATCH = 0 STRING = [MAJOR, MINOR, PATCH].join(".") From 93b3cdca634d5832b1b96aa784ebfa43aed1f5ce Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Thu, 26 Mar 2020 16:16:43 -0700 Subject: [PATCH 33/35] Add some documentation to the readme --- README.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/README.md b/README.md index 01f0d1bda..efdcbb82c 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,58 @@ end To assert that a component has not been rendered, use `refute_component_rendered` from `ViewComponent::TestHelpers`. +### Rendering collections + +It's possible to render collections with components: + +`app/view/products/index.html.erb` +``` erb +<%= render(ProductComponent.with_collection(@products)) %> +``` + +Where the `ProductComponent` and associated template might look something like the following. Notice that the constructor must take a `product` and the name of that parameter matches the name of the component. + +`app/components/product_component.rb` +``` ruby +class ProductComponent < ViewComponent::Base + def initialize(product:) + @product = product + end +end +``` + +`app/components/product_component.html.erb` +``` erb +
  • <%= @product.name %>
  • +``` + +Additionally, extra arguments can be passed to the component and the name of the parameter can be changed: + +`app/view/products/index.html.erb` +``` erb +<%= render(ProductComponent.with_collection(@products, notice: "hi")) %> +``` + +`app/components/product_component.rb` +``` ruby +class ProductComponent < ViewComponent::Base + with_collection_parameter :item + + def initialize(item:, notice:) + @item = item + @notice = notice + end +end +``` + +`app/components/product_component.html.erb` +``` erb +
  • +

    <%= @item.name %>

    + <%= @notice %> +
  • +``` + ### Testing Unit test components directly, using the `render_inline` test helper and Capybara matchers: From b09ca46f296a89a4bfcf5bcea9afe2e93de98f2c Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Fri, 27 Mar 2020 08:10:16 -0700 Subject: [PATCH 34/35] Didn't mean to replace these --- test/view_component/view_component_test.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/view_component/view_component_test.rb b/test/view_component/view_component_test.rb index 35e3d47bf..ddda611e2 100644 --- a/test/view_component/view_component_test.rb +++ b/test/view_component/view_component_test.rb @@ -99,7 +99,7 @@ def test_renders_content_for_template end def test_renders_content_areas_template_with_initialize_arguments - render_inline(ContentAreasComponent.new(title: "Radio clock!", footer: "Bye!")) do |component| + render_inline(ContentAreasComponent.new(title: "Hi!", footer: "Bye!")) do |component| component.with(:body) { "Have a nice day." } end end @@ -129,11 +129,11 @@ def test_renders_content_areas_template_with_block def test_renders_content_areas_template_replaces_content render_inline(ContentAreasComponent.new(footer: "Bye!")) do |component| component.with(:title) { "Hello!" } - component.with(:title, "Radio clock!") + component.with(:title, "Hi!") component.with(:body) { "Have a nice day." } end - assert_selector(".title", text: "Radio clock!") + assert_selector(".title", text: "Hi!") assert_selector(".body", text: "Have a nice day.") assert_selector(".footer", text: "Bye!") end From c32d0660a1bf02510ddd0507bff591ca4e3a915e Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Fri, 27 Mar 2020 10:20:29 -0600 Subject: [PATCH 35/35] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ad673b9e..afe7b1d83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ * Support rendering collections (e.g., `render(MyComponent.with_collection(@items))`). - *Tim Clem, Joel Hawksley* + *Tim Clem* # v2.0.0