diff --git a/CHANGELOG.md b/CHANGELOG.md index f108f7c..4fcb257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## master +- Add `#wrapped_in` helper to support wrappers that are conditional on multiple child components +- Add `#placeholder` method to `ViewComponentContrib::WrapperComponent` that only renders a block if no conditional child components render + ## 0.2.5 (2025-08-11) - Fix compatibility with view_component v4. diff --git a/README.md b/README.md index 0c8124c..b82c5a2 100644 --- a/README.md +++ b/README.md @@ -765,6 +765,8 @@ You can add this to following line to your component generator (unless it's alre ## Wrapped components +### Wrapping a single component + Sometimes we need to wrap a component into a custom HTML container (for positioning or whatever). By default, such wrapping doesn't play well with the `#render?` method because if we don't need a component, we don't need a wrapper. To solve this problem, we introduce a special `ViewComponentContrib::WrapperComponent` class: it takes any component as the only argument and accepts a block during rendering to define a wrapping HTML. And it renders only if the _inner component_'s `#render?` method returns true. @@ -797,7 +799,101 @@ And the template looks like this now: <%- end -%> ``` -You can use the `#wrapped` method on any component inherited from `ApplicationViewComponent` to wrap it automatically: +You can use the `#wrapped` method on any component inherited from `ApplicationViewComponent` to wrap it automatically. + +### Wrapping multiple components + +Sometimes a wrapper needs to wrap multiple components, and only render if at least one of the inner components renders. + +For example, consider a container element with a title and two components. + +```erb +
+

Title

+
+ <%= render ExampleA::Component %> + <%= render ExampleB::Component %> +
+
+``` + +Both components have their own `#render?` method. If neither of the components render, then we don't want to render the container either. + +We introduce the `#wrapped_in` method that can be called on any component rendered within a `ViewComponentContrib::WrapperComponent`. Calling `#wrapped_in` on a child component will _register_ that component with the wrapper. The wrapper and its contents will only render if _at least one_ of the registered components' `#render?` methods returns true. + +```erb +<%= render ViewComponentContrib::WrapperComponent.new do |wrapper| %> +
+

Title

+
+ <%= render ExampleA::Component.new.wrapped_in(wrapper) %> + <%= render ExampleB::Component.new.wrapped_in(wrapper) %> +
+
+<%- end -%> +``` + +To use the method, include the helper in your base ViewComponent class: + +```ruby +class ApplicationViewComponent < ViewComponent::Base + # adds #wrapped_in method + # NOTE: Already included into ViewComponentContrib::Base + include ViewComponentContrib::WrappedInHelper +end +``` + +You can place any content inside a wrapper component. You can even nest wrapper components: + +```erb + +<%= render ViewComponentContrib::WrapperComponent.new do |wrapper| %> +
+

Examples

+ + + <%= render ViewComponentContrib::WrapperComponent.new.wrapped_in(wrapper) do |foo_wrapper| %> +
+

Foo Examples

+
+ <%= render FooExampleA::Component.new.wrapped_in(foo_wrapper) %> + <%= render FooExampleB::Component.new.wrapped_in(foo_wrapper) %> +
+
+ <%- end -%> + + + <%= render ViewComponentContrib::WrapperComponent.new.wrapped_in(wrapper) do |bar_wrapper| %> +
+

Bar Examples

+
+ <%= render BarExampleA::Component.new.wrapped_in(bar_wrapper) %> + <%= render BarExampleB::Component.new.wrapped_in(bar_wrapper) %> +
+
+ <%- end -%> +
+<%- end -%> +``` + +You can also use the `#placeholder` method on a wrapper component to render a block _only_ if none of the registered components render. + +```erb +<%= render ViewComponentContrib::WrapperComponent.new do |wrapper| %> +
+

Title

+
+ <%= render ExampleA::Component.new.wrapped_in(wrapper) %> + <%= render ExampleB::Component.new.wrapped_in(wrapper) %> +
+
+ + + <%- wrapper.placeholder do -%> + Examples coming soon! + <%- end -%> +<%- end -%> +``` ## License diff --git a/lib/view_component_contrib.rb b/lib/view_component_contrib.rb index af66467..2b20996 100644 --- a/lib/view_component_contrib.rb +++ b/lib/view_component_contrib.rb @@ -11,6 +11,7 @@ module ViewComponentContrib autoload :TranslationHelper, "view_component_contrib/translation_helper" autoload :WrapperComponent, "view_component_contrib/wrapper_component" autoload :WrappedHelper, "view_component_contrib/wrapped_helper" + autoload :WrappedInHelper, "view_component_contrib/wrapped_in_helper" autoload :StyleVariants, "view_component_contrib/style_variants" autoload :Base, "view_component_contrib/base" diff --git a/lib/view_component_contrib/base.rb b/lib/view_component_contrib/base.rb index 312b982..9376757 100644 --- a/lib/view_component_contrib/base.rb +++ b/lib/view_component_contrib/base.rb @@ -5,5 +5,6 @@ module ViewComponentContrib class Base < ViewComponent::Base include TranslationHelper include WrappedHelper + include WrappedInHelper end end diff --git a/lib/view_component_contrib/wrapped_in_helper.rb b/lib/view_component_contrib/wrapped_in_helper.rb new file mode 100644 index 0000000..ece2c1e --- /dev/null +++ b/lib/view_component_contrib/wrapped_in_helper.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module ViewComponentContrib + # Adds `#wrapped_in` method to register a component with a wrapper + module WrappedInHelper + def wrapped_in(wrapper) + raise ArgumentError, "Expected a ViewComponentContrib::WrapperComponent" unless wrapper.is_a?(WrapperComponent) + + wrapper.register(self) + self + end + end +end diff --git a/lib/view_component_contrib/wrapper_component.rb b/lib/view_component_contrib/wrapper_component.rb index 4b063eb..9e7eb02 100644 --- a/lib/view_component_contrib/wrapper_component.rb +++ b/lib/view_component_contrib/wrapper_component.rb @@ -1,34 +1,58 @@ # frozen_string_literal: true module ViewComponentContrib - # WrapperComponent allwows to wrap any component with a custom HTML code. + # WrapperComponent allows to wrap any component with a custom HTML code. # The whole wrapper is only rendered when the child component.render? returns true. # Thus, wrapper could be used to conditionally render the outer html for components without # conditionals in templates. class WrapperComponent < ViewComponent::Base + include WrappedInHelper + class DoubleRenderError < StandardError def initialize(component) super("A child component could only be rendered once within a wrapper: #{component}") end end - attr_reader :component_instance + class ComponentPresentError < StandardError + def initialize + super("A wrapper component cannot register a component if it already has a component from the constructor") + end + end - delegate :render?, to: :component_instance + attr_reader :component_instance, :registered_components, :placeholder_block + + # We need to touch `content` before the `render?` method is called, + # otherwise children calling `.wrapped_in` won't be registered. + # This overrides the default lazy evaluation of `content` in ViewComponent, + # but it's necessary for the wrapper to work properly. + def before_render + content if component_instance.blank? + end - def initialize(component) + def initialize(component = nil) @component_instance = component + @registered_components = [] + end + + def render? + return component_instance.render? if component_instance.present? + return true if render_from_registered_components? + + @placeholder_block.present? end # Simply return the contents of the block passed to #render_component. # (Alias couldn't be used here 'cause ViewComponent check for the method presence when # choosing between #call and a template.) def call + return view_context.capture(&@placeholder_block) if render_placeholder? + content end # Returns rendered child component - # The name component is chosen for convienent usage in templates, + # The name component is chosen for convenient usage in templates, # so we can simply call `= wrapper.component` in the place where we're going # to put the component def component @@ -36,5 +60,39 @@ def component @rendered = component_instance.render_in(view_context).html_safe end + + # Register a component to be rendered within the wrapper. + # If no registered components render, the wrapper itself won't be rendered. + def register(component) + raise ComponentPresentError if component_instance.present? + raise ArgumentError, "Expected a ViewComponent" unless component.is_a?(ViewComponent::Base) + + registered_components << component + end + + # Register a placeholder block: `wrapper.placeholder { "Nothing to show" }` + # The block is only emitted when: + # - no component instance was supplied, AND + # - every registered component’s `render?` returns false + def placeholder(&block) + raise ArgumentError, "Block required" unless block + + @placeholder_block = block + nil + end + + private + + # Memoize the result of registered components' render? calls as they + # could be expensive + def render_from_registered_components? + @render_from_registered_components ||= registered_components.any?(&:render?) + end + + def render_placeholder? + @placeholder_block.present? && + component_instance.blank? && + !render_from_registered_components? + end end end diff --git a/test/cases/wrapped_in_test.rb b/test/cases/wrapped_in_test.rb new file mode 100644 index 0000000..5272961 --- /dev/null +++ b/test/cases/wrapped_in_test.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require "test_helper" + +class WrappedInTest < ViewTestCase + class Component < ViewComponentContrib::Base + attr_reader :should_render + + def initialize(should_render: true) + @should_render = should_render + end + + alias_method :render?, :should_render + + def call + "Hello from test".html_safe + end + end + + class NonViewComponent + include ViewComponentContrib::WrappedInHelper + + def call + "Hello from test".html_safe + end + end + + def test_renders_when_two_inner_components_render + inner_component_a = Component.new + inner_component_b = Component.new + wrapper_component = ViewComponentContrib::WrapperComponent.new + + render_inline(wrapper_component) do |wrapper| + "

Title

" \ + "
#{render_inline(inner_component_a.wrapped_in(wrapper)).to_html}
" \ + "
#{render_inline(inner_component_b.wrapped_in(wrapper)).to_html}
".html_safe + end + + assert_selector page, "h3", count: 1, text: "Title" + assert_selector page, "div", count: 2 + assert_selector page, "div", text: "Hello from test", count: 2 + end + + def test_renders_when_one_inner_component_renders + inner_component_a = Component.new + inner_component_b = Component.new(should_render: false) + wrapper_component = ViewComponentContrib::WrapperComponent.new + + render_inline(wrapper_component) do |wrapper| + "

Title

" \ + "
#{render_inline(inner_component_a.wrapped_in(wrapper)).to_html}
" \ + "
#{render_inline(inner_component_b.wrapped_in(wrapper)).to_html}
".html_safe + end + + assert_selector page, "h3", count: 1, text: "Title" + assert_selector page, "div", count: 2 + assert_selector page, "div", text: "Hello from test", count: 1 + end + + def test_does_not_render_when_no_inner_components_render + inner_component_a = Component.new(should_render: false) + inner_component_b = Component.new(should_render: false) + wrapper_component = ViewComponentContrib::WrapperComponent.new + + render_inline(wrapper_component) do |wrapper| + "

Title

" \ + "
#{render_inline(inner_component_a.wrapped_in(wrapper)).to_html}
" \ + "
#{render_inline(inner_component_b.wrapped_in(wrapper)).to_html}
".html_safe + end + + assert_no_selector page, "h3" + assert_no_selector page, "div" + end + + def test_outer_wrapper_renders_when_one_inner_wrapper_renders + inner_components_a = [Component.new, Component.new(should_render: false)] + inner_components_b = [Component.new(should_render: false), Component.new(should_render: false)] + inner_wrapper_component_a = ViewComponentContrib::WrapperComponent.new + inner_wrapper_component_b = ViewComponentContrib::WrapperComponent.new + outer_wrapper_component = ViewComponentContrib::WrapperComponent.new + + render_inline(outer_wrapper_component) do |outer_wrapper| + "

Title

" \ + "#{ + render_inline(inner_wrapper_component_a.wrapped_in(outer_wrapper)) do |inner_wrapper_a| + "

Subtitle A

" \ + "
#{render_inline(inner_components_a[0].wrapped_in(inner_wrapper_a)).to_html}
" \ + "
#{render_inline(inner_components_a[1].wrapped_in(inner_wrapper_a)).to_html}
".html_safe + end + }" \ + "#{ + render_inline(inner_wrapper_component_b.wrapped_in(outer_wrapper)) do |inner_wrapper_b| + "

Subtitle B

" \ + "
#{render_inline(inner_components_b[0].wrapped_in(inner_wrapper_b)).to_html}
" \ + "
#{render_inline(inner_components_b[1].wrapped_in(inner_wrapper_b)).to_html}
".html_safe + end + }".html_safe + end + + assert_selector page, "h3", count: 1, text: "Title" + assert_selector page, "h3", count: 1, text: "Subtitle A" + assert_no_selector page, "h3", text: "Subtitle B" + assert_selector page, "div", text: "Hello from test", count: 1 + end + + def test_outer_wrapper_does_not_render_when_no_inner_wrappers_render + inner_components_a = [Component.new(should_render: false), Component.new(should_render: false)] + inner_components_b = [Component.new(should_render: false), Component.new(should_render: false)] + inner_wrapper_component_a = ViewComponentContrib::WrapperComponent.new + inner_wrapper_component_b = ViewComponentContrib::WrapperComponent.new + outer_wrapper_component = ViewComponentContrib::WrapperComponent.new + + render_inline(outer_wrapper_component) do |outer_wrapper| + "

Title

" \ + "#{ + render_inline(inner_wrapper_component_a.wrapped_in(outer_wrapper)) do |inner_wrapper_a| + "

Subtitle A

" \ + "
#{render_inline(inner_components_a[0].wrapped_in(inner_wrapper_a)).to_html}
" \ + "
#{render_inline(inner_components_a[1].wrapped_in(inner_wrapper_a)).to_html}
".html_safe + end + }" \ + "#{ + render_inline(inner_wrapper_component_b.wrapped_in(outer_wrapper)) do |inner_wrapper_b| + "

Subtitle B

" \ + "
#{render_inline(inner_components_b[0].wrapped_in(inner_wrapper_b)).to_html}
" \ + "
#{render_inline(inner_components_b[1].wrapped_in(inner_wrapper_b)).to_html}
".html_safe + end + }".html_safe + end + + assert_no_selector page, "h3" + assert_no_selector page, "div" + end + + def test_raises_error_when_passing_non_wrapper_component + component_a = Component.new + component_b = Component.new + + assert_raises(ArgumentError) do + component_a.wrapped_in(component_b) + end + end + + def test_raises_error_when_passing_non_view_component + non_view_component = NonViewComponent.new + wrapper_component = ViewComponentContrib::WrapperComponent.new + + assert_raises(ArgumentError) do + non_view_component.wrapped_in(wrapper_component) + end + end + + def test_placeholder_does_not_render_when_any_inner_component_renders + inner_component = Component.new + wrapper_component = ViewComponentContrib::WrapperComponent.new + + render_inline(wrapper_component) do |wrapper| + "

Title

" \ + "
#{render_inline(inner_component.wrapped_in(wrapper)).to_html}
" \ + "#{wrapper.placeholder { "
Nothing to show
".html_safe }}".html_safe + end + + assert_selector page, "h3", count: 1, text: "Title" + assert_selector page, "div", text: "Hello from test", count: 1 + assert_no_selector page, "div", text: "Nothing to show" + end + + def test_placeholder_renders_when_no_inner_component_renders + inner_component = Component.new(should_render: false) + wrapper_component = ViewComponentContrib::WrapperComponent.new + + render_inline(wrapper_component) do |wrapper| + "

Title

" \ + "
#{render_inline(inner_component.wrapped_in(wrapper)).to_html}
" \ + "#{wrapper.placeholder { "
Nothing to show
".html_safe }}".html_safe + end + + assert_no_selector page, "h3", count: 1, text: "Title" + assert_no_selector page, "div", text: "Hello from test" + assert_selector page, "div", text: "Nothing to show", count: 1 + end +end