diff --git a/CHANGELOG.md b/CHANGELOG.md
index 33014c2d8..afe7b1d83 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# master
+# v2.1.0
+
+* Support rendering collections (e.g., `render(MyComponent.with_collection(@items))`).
+
+ *Tim Clem*
+
# v2.0.0
* Move to `ViewComponent` namespace, removing all references to `ActionView`.
diff --git a/README.md b/README.md
index f53951c12..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:
@@ -446,15 +498,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|
-|Boston, MA|
-
-||
-|:---:|
-|@seanpdoyle|
-|New York, NY|
+||||
+|:---:|:---:|:---:|
+|@blakewilliams|@seanpdoyle|@tclem|
+|Boston, MA|New York, NY|San Francisco, CA|
## License
diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb
index bd8f23f1d..a92dffe06 100644
--- a/lib/view_component/base.rb
+++ b/lib/view_component/base.rb
@@ -2,6 +2,7 @@
require "action_view"
require "active_support/configurable"
+require "view_component/collection"
require "view_component/previewable"
module ViewComponent
@@ -14,6 +15,11 @@ class Base < ActionView::Base
class_attribute :content_areas, default: []
self.content_areas = [] # default doesn't work until Rails 5.2
+ # Render a component collection.
+ def self.with_collection(*args)
+ Collection.new(self, *args)
+ end
+
# Entrypoint for rendering components.
#
# view_context: ActionView context from calling view
@@ -213,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
new file mode 100644
index 000000000..029122bcf
--- /dev/null
+++ b/lib/view_component/collection.rb
@@ -0,0 +1,30 @@
+
+# frozen_string_literal: true
+
+module ViewComponent
+ class Collection
+ def render_in(view_context, &block)
+ as = @component.collection_parameter_name
+
+ @collection.map do |item|
+ @component.new(@options.merge(as => item)).render_in(view_context, &block)
+ end.join.html_safe
+ end
+
+ private
+
+ def initialize(component, object, **options)
+ @component = component
+ @collection = collection_variable(object || [])
+ @options = options
+ end
+
+ def collection_variable(object)
+ if object.respond_to?(:to_ary)
+ object.to_ary
+ else
+ raise ArgumentError.new("The value of the argument isn't a valid collection. Make sure it responds to to_ary: #{object.inspect}")
+ end
+ end
+ end
+end
diff --git a/test/app/components/product_component.html.erb b/test/app/components/product_component.html.erb
new file mode 100644
index 000000000..51c11d789
--- /dev/null
+++ b/test/app/components/product_component.html.erb
@@ -0,0 +1,7 @@
+
+ Product
+ <%= @product.name %>
+
+ <%= @notice %>
+
+
diff --git a/test/app/components/product_component.rb b/test/app/components/product_component.rb
new file mode 100644
index 000000000..12baa3661
--- /dev/null
+++ b/test/app/components/product_component.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class ProductComponent < ViewComponent::Base
+ def initialize(product:, notice:)
+ @product = product
+ @notice = notice
+ end
+end
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..b25a06ede
--- /dev/null
+++ b/test/app/components/product_coupon_component.html.erb
@@ -0,0 +1 @@
+<%= @item.percent_off %>%
diff --git a/test/app/components/product_coupon_component.rb b/test/app/components/product_coupon_component.rb
new file mode 100644
index 000000000..a93694265
--- /dev/null
+++ b/test/app/components/product_coupon_component.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class ProductCouponComponent < ViewComponent::Base
+ with_collection_parameter :item
+
+ def initialize(item:)
+ @item = item
+ end
+end
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
diff --git a/test/view_component/view_component_test.rb b/test/view_component/view_component_test.rb
index 4ead2fa8e..ddda611e2 100644
--- a/test/view_component/view_component_test.rb
+++ b/test/view_component/view_component_test.rb
@@ -386,6 +386,60 @@ 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
+
+ def test_render_collection
+ 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")
+ assert_selector("h2", text: "Mints")
+ assert_selector("p", text: "On sale", count: 2)
+ 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))
+
+ assert_selector("h3", text: "20%")
+ assert_selector("h3", text: "50%")
+ end
+
+ def test_render_collection_nil_and_empty_collection
+ [nil, []].each do |collection|
+ render_inline(ProductComponent.with_collection(collection, notice: "On sale"))
+
+ assert_no_text("Products")
+ end
+ end
+
+ def test_render_collection_missing_collection_object
+ exception = assert_raises ArgumentError do
+ render_inline(ProductComponent.with_collection(notice: "On sale"))
+ end
+
+ 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_missing_arg
+ products = [OpenStruct.new(name: "Radio clock"), OpenStruct.new(name: "Mints")]
+ exception = assert_raises ArgumentError do
+ render_inline(ProductComponent.with_collection(products))
+ end
+
+ assert_match /missing keyword/, exception.message
+ assert_match /notice/, exception.message
+ end
+
+ def test_render_single_item_from_collection
+ 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")
+ assert_selector("p", text: "On sale", count: 1)
+ end
+
private
def modify_file(file, content)