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| -|:---:| -|@blakewilliams| -|Boston, MA| - -|seanpdoyle| -|:---:| -|@seanpdoyle| -|New York, NY| +|blakewilliams|seanpdoyle|tclem| +|:---:|:---:|:---:| +|@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

    + 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)