Skip to content

Commit

Permalink
Merge pull request #274 from github/render-collections
Browse files Browse the repository at this point in the history
Render collections
  • Loading branch information
joelhawksley authored Mar 27, 2020
2 parents adf26fc + c32d066 commit e40707a
Show file tree
Hide file tree
Showing 13 changed files with 205 additions and 9 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down
65 changes: 56 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<li><%= @product.name %></li>
```

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
<li>
<h2><%= @item.name %></h2>
<span><%= @notice %></span>
</li>
```

### Testing

Unit test components directly, using the `render_inline` test helper and Capybara matchers:
Expand Down Expand Up @@ -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|

|<img src="https://avatars.githubusercontent.com/blakewilliams?s=256" alt="blakewilliams" width="128" />|
|:---:|
|@blakewilliams|
|Boston, MA|

|<img src="https://avatars.githubusercontent.com/seanpdoyle?s=256" alt="seanpdoyle" width="128" />|
|:---:|
|@seanpdoyle|
|New York, NY|
|<img src="https://avatars.githubusercontent.com/blakewilliams?s=256" alt="blakewilliams" width="128" />|<img src="https://avatars.githubusercontent.com/seanpdoyle?s=256" alt="seanpdoyle" width="128" />|<img src="https://avatars.githubusercontent.com/tclem?s=256" alt="tclem" width="128" />|
|:---:|:---:|:---:|
|@blakewilliams|@seanpdoyle|@tclem|
|Boston, MA|New York, NY|San Francisco, CA|

## License

Expand Down
15 changes: 15 additions & 0 deletions lib/view_component/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require "action_view"
require "active_support/configurable"
require "view_component/collection"
require "view_component/previewable"

module ViewComponent
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions lib/view_component/collection.rb
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions test/app/components/product_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<li>
<h1>Product</h1>
<h2><%= @product.name %></h2>
<p>
<%= @notice %>
</p>
</li>
8 changes: 8 additions & 0 deletions test/app/components/product_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

class ProductComponent < ViewComponent::Base
def initialize(product:, notice:)
@product = product
@notice = notice
end
end
1 change: 1 addition & 0 deletions test/app/components/product_coupon_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h3><%= @item.percent_off %>%</h3>
9 changes: 9 additions & 0 deletions test/app/components/product_coupon_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

class ProductCouponComponent < ViewComponent::Base
with_collection_parameter :item

def initialize(item:)
@item = item
end
end
4 changes: 4 additions & 0 deletions test/app/controllers/integration_examples_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions test/app/views/integration_examples/products.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<h1>Products for sale</h1>
<ul>
<%= render(ProductComponent.with_collection(@products, notice: "Today only")) %>
</ul>
1 change: 1 addition & 0 deletions test/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 10 additions & 0 deletions test/view_component/integration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
54 changes: 54 additions & 0 deletions test/view_component/view_component_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,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)
Expand Down

0 comments on commit e40707a

Please sign in to comment.