From 268a3bd0e3dc8af3d29d4da130aa2e89b8a48958 Mon Sep 17 00:00:00 2001 From: Ollie James Date: Fri, 25 Apr 2025 15:24:00 +0100 Subject: [PATCH 01/20] Add ShowIfWrapper component --- lib/view_component_contrib.rb | 1 + .../show_if_wrapper_component.rb | 40 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 lib/view_component_contrib/show_if_wrapper_component.rb diff --git a/lib/view_component_contrib.rb b/lib/view_component_contrib.rb index af66467..77e0e80 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 :ShowIfWrapperComponent, "view_component_contrib/show_if_wrapper_component" autoload :StyleVariants, "view_component_contrib/style_variants" autoload :Base, "view_component_contrib/base" diff --git a/lib/view_component_contrib/show_if_wrapper_component.rb b/lib/view_component_contrib/show_if_wrapper_component.rb new file mode 100644 index 0000000..15d6230 --- /dev/null +++ b/lib/view_component_contrib/show_if_wrapper_component.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module ViewComponentContrib + class ShowIfWrapperComponent < ViewComponent::Base + # ShowIfWrapperComponent will only render if at least one of the + # conditional fragments produces output. + # A conditional fragment is declared by wrapping markup in a + # `show_if` block. + + attr_reader :conditional_fragments + + def before_render + @captured_content = content + end + + def initialize + @conditional_fragments = [] + end + + def call + @captured_content if conditions_passed? + end + + # Call this inside the template: = w.show_if { … } + # Captures the block, pushes it into the list, and + # returns the HTML string so it appears inline where it was declared + def show_if(&block) + view_context.capture(&block).tap do |html| + conditional_fragments << html + end + end + + # Render the wrapper itself only if: + # - at least one conditional fragment produced output, OR + # - no conditional fragments were declared + def conditions_passed? + conditional_fragments.empty? || conditional_fragments.any?(&:present?) + end + end +end From e2832bb0de99ef9c60ee2e7a3a7af076b258c0ba Mon Sep 17 00:00:00 2001 From: Ollie James Date: Fri, 25 Apr 2025 15:24:05 +0100 Subject: [PATCH 02/20] Add tests --- test/cases/show_if_wrapper_component_test.rb | 66 ++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 test/cases/show_if_wrapper_component_test.rb diff --git a/test/cases/show_if_wrapper_component_test.rb b/test/cases/show_if_wrapper_component_test.rb new file mode 100644 index 0000000..3b8682a --- /dev/null +++ b/test/cases/show_if_wrapper_component_test.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "test_helper" + +class ShowIfWrapperComponentTest < 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 + + def test_renders_when_two_inner_components_render + inner_component_a = Component.new + inner_component_b = Component.new + wrapper_component = ViewComponentContrib::ShowIfWrapperComponent.new + + render_inline(wrapper_component) do |wrapper| + "

Title

" \ + "
#{wrapper.show_if { render_inline(inner_component_a).to_html }}
" \ + "
#{wrapper.show_if { render_inline(inner_component_b).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::ShowIfWrapperComponent.new + + render_inline(wrapper_component) do |wrapper| + "

Title

" \ + "
#{wrapper.show_if { render_inline(inner_component_a).to_html }}
" \ + "
#{wrapper.show_if { render_inline(inner_component_b).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::ShowIfWrapperComponent.new + + render_inline(wrapper_component) do |wrapper| + "

Title

" \ + "
#{wrapper.show_if { render_inline(inner_component_a).to_html }}
" \ + "
#{wrapper.show_if { render_inline(inner_component_b).to_html }}
".html_safe + end + + assert_no_selector page, "h3" + assert_no_selector page, "div" + end +end From 1ffef3119e68d607259b1d6c1448d06b2b292b02 Mon Sep 17 00:00:00 2001 From: Ollie James Date: Fri, 25 Apr 2025 15:45:31 +0100 Subject: [PATCH 03/20] Add section to README.md --- README.md | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f26be9b..ad258d5 100644 --- a/README.md +++ b/README.md @@ -761,6 +761,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. @@ -793,7 +795,45 @@ 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 a special `ViewComponentContrib::ShowIfWrapperComponent` class that wraps around the container. We can declare the conditional parts of the markup with the `.show_if` block. + +```erb +<%= render ViewComponentContrib::ShowIfWrapperComponent.new do |wrapper| %> +
+

Title

+
+ <%= wrapper.show_if do %> + <%= render ExampleA::Component %> + <%- end -> + <%= wrapper.show_if do %> + <%= render ExampleB::Component %> + <%- end -> +
+
+<%- end -%> +``` + +If _at least one_ of the `.show_if` blocks renders something, all of the content within the wrapper component is rendered. Otherwise, if nothing is rendered in _any_ of the blocks, the wrapper component doesn't render. ## License From 6ab0b71436828df88920d9c692492e4679d44245 Mon Sep 17 00:00:00 2001 From: Ollie James Date: Fri, 25 Apr 2025 15:47:30 +0100 Subject: [PATCH 04/20] Rejig sentence order --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ad258d5..90b5d2c 100644 --- a/README.md +++ b/README.md @@ -833,7 +833,7 @@ We introduce a special `ViewComponentContrib::ShowIfWrapperComponent` class that <%- end -%> ``` -If _at least one_ of the `.show_if` blocks renders something, all of the content within the wrapper component is rendered. Otherwise, if nothing is rendered in _any_ of the blocks, the wrapper component doesn't render. +If nothing is rendered in _any_ of the blocks, the wrapper component doesn't render. Otherwise, if _at least one_ of the `.show_if` blocks renders something, all of the content within the wrapper component is rendered. ## License From e45020efdd495ed2c44c913c650f3bd9efc1f316 Mon Sep 17 00:00:00 2001 From: Ollie James Date: Fri, 25 Apr 2025 17:05:19 +0100 Subject: [PATCH 05/20] Fix ERB typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 90b5d2c..19e905d 100644 --- a/README.md +++ b/README.md @@ -830,7 +830,7 @@ We introduce a special `ViewComponentContrib::ShowIfWrapperComponent` class that <%- end -> -<%- end -%> +<%- end -> ``` If nothing is rendered in _any_ of the blocks, the wrapper component doesn't render. Otherwise, if _at least one_ of the `.show_if` blocks renders something, all of the content within the wrapper component is rendered. From 883cc545284def2b1028ebf67289543803b5354f Mon Sep 17 00:00:00 2001 From: Ollie James Date: Fri, 25 Apr 2025 17:08:48 +0100 Subject: [PATCH 06/20] Add changelog entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56f297d..7f591df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## master +- Add `ShowIfWrapperComponent` for wrappers that are conditional on multiple child components + ## 0.2.4 (2025-01-03) - Add inheritance strategies to style variants ([@omarluq][]) From 8f4d610c5a80e495043f405eeb14ea95c03b5b5b Mon Sep 17 00:00:00 2001 From: Ollie James Date: Fri, 25 Apr 2025 17:23:25 +0100 Subject: [PATCH 07/20] Fix ERB typo properly this time --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 19e905d..d446ded 100644 --- a/README.md +++ b/README.md @@ -824,13 +824,13 @@ We introduce a special `ViewComponentContrib::ShowIfWrapperComponent` class that
<%= wrapper.show_if do %> <%= render ExampleA::Component %> - <%- end -> + <%- end -%> <%= wrapper.show_if do %> <%= render ExampleB::Component %> - <%- end -> + <%- end -%>
-<%- end -> +<%- end -%> ``` If nothing is rendered in _any_ of the blocks, the wrapper component doesn't render. Otherwise, if _at least one_ of the `.show_if` blocks renders something, all of the content within the wrapper component is rendered. From 3f31094a66e292573635c15711034c2e48b0868c Mon Sep 17 00:00:00 2001 From: Ollie James Date: Fri, 16 May 2025 10:51:11 +0100 Subject: [PATCH 08/20] Rework API to use `wrapped_in` method instead --- lib/view_component_contrib.rb | 1 + lib/view_component_contrib/base.rb | 1 + .../wrapped_in_helper.rb | 13 ++++++++ .../wrapper_component.rb | 32 ++++++++++++++++--- 4 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 lib/view_component_contrib/wrapped_in_helper.rb diff --git a/lib/view_component_contrib.rb b/lib/view_component_contrib.rb index 77e0e80..c5b7bcc 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 :ShowIfWrapperComponent, "view_component_contrib/show_if_wrapper_component" autoload :StyleVariants, "view_component_contrib/style_variants" 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..08d5b62 100644 --- a/lib/view_component_contrib/wrapper_component.rb +++ b/lib/view_component_contrib/wrapper_component.rb @@ -1,7 +1,7 @@ # 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. @@ -12,12 +12,25 @@ def initialize(component) end end - attr_reader :component_instance + attr_reader :component_instance, :registered_components - delegate :render?, to: :component_instance + # 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 + end - def initialize(component) + def initialize(component = nil) @component_instance = component + @registered_components = [] + end + + def render? + return component_instance.render? if component_instance.present? + + registered_components.any?(&:render?) end # Simply return the contents of the block passed to #render_component. @@ -28,7 +41,7 @@ def call 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 +49,14 @@ 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 ArgumentError, "Wrapper already has a component" if component_instance.present? + raise ArgumentError, "Expected a ViewComponent" unless component.is_a?(ViewComponent::Base) + + registered_components << component + end end end From fb6c170eeee81183a355f5bee1360253f98eb319 Mon Sep 17 00:00:00 2001 From: Ollie James Date: Fri, 16 May 2025 10:51:38 +0100 Subject: [PATCH 09/20] Update existing tests --- ...r_component_test.rb => wrapped_in_test.rb} | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) rename test/cases/{show_if_wrapper_component_test.rb => wrapped_in_test.rb} (64%) diff --git a/test/cases/show_if_wrapper_component_test.rb b/test/cases/wrapped_in_test.rb similarity index 64% rename from test/cases/show_if_wrapper_component_test.rb rename to test/cases/wrapped_in_test.rb index 3b8682a..3d048de 100644 --- a/test/cases/show_if_wrapper_component_test.rb +++ b/test/cases/wrapped_in_test.rb @@ -2,7 +2,7 @@ require "test_helper" -class ShowIfWrapperComponentTest < ViewTestCase +class WrappedInTest < ViewTestCase class Component < ViewComponentContrib::Base attr_reader :should_render @@ -20,12 +20,12 @@ def call def test_renders_when_two_inner_components_render inner_component_a = Component.new inner_component_b = Component.new - wrapper_component = ViewComponentContrib::ShowIfWrapperComponent.new + wrapper_component = ViewComponentContrib::WrapperComponent.new render_inline(wrapper_component) do |wrapper| "

Title

" \ - "
#{wrapper.show_if { render_inline(inner_component_a).to_html }}
" \ - "
#{wrapper.show_if { render_inline(inner_component_b).to_html }}
".html_safe + "
#{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" @@ -36,12 +36,12 @@ def test_renders_when_two_inner_components_render def test_renders_when_one_inner_component_renders inner_component_a = Component.new inner_component_b = Component.new(should_render: false) - wrapper_component = ViewComponentContrib::ShowIfWrapperComponent.new + wrapper_component = ViewComponentContrib::WrapperComponent.new render_inline(wrapper_component) do |wrapper| "

Title

" \ - "
#{wrapper.show_if { render_inline(inner_component_a).to_html }}
" \ - "
#{wrapper.show_if { render_inline(inner_component_b).to_html }}
".html_safe + "
#{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" @@ -52,12 +52,12 @@ def test_renders_when_one_inner_component_renders 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::ShowIfWrapperComponent.new + wrapper_component = ViewComponentContrib::WrapperComponent.new render_inline(wrapper_component) do |wrapper| "

Title

" \ - "
#{wrapper.show_if { render_inline(inner_component_a).to_html }}
" \ - "
#{wrapper.show_if { render_inline(inner_component_b).to_html }}
".html_safe + "
#{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" From ce8851d6bbcb516770f7873cd2dd9ecd92c206a9 Mon Sep 17 00:00:00 2001 From: Ollie James Date: Fri, 16 May 2025 11:18:13 +0100 Subject: [PATCH 10/20] Add tests for nested wrappers --- .../wrapper_component.rb | 2 + test/cases/wrapped_in_test.rb | 60 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/lib/view_component_contrib/wrapper_component.rb b/lib/view_component_contrib/wrapper_component.rb index 08d5b62..f931681 100644 --- a/lib/view_component_contrib/wrapper_component.rb +++ b/lib/view_component_contrib/wrapper_component.rb @@ -6,6 +6,8 @@ module ViewComponentContrib # 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}") diff --git a/test/cases/wrapped_in_test.rb b/test/cases/wrapped_in_test.rb index 3d048de..b7c23ff 100644 --- a/test/cases/wrapped_in_test.rb +++ b/test/cases/wrapped_in_test.rb @@ -63,4 +63,64 @@ def test_does_not_render_when_no_inner_components_render 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 end From b35cbc7fe4b28bc6d9c71bf5078e273aacf4d9e6 Mon Sep 17 00:00:00 2001 From: Ollie James Date: Fri, 16 May 2025 11:26:50 +0100 Subject: [PATCH 11/20] Remove `ShowIfWrapperComponent` --- lib/view_component_contrib.rb | 1 - .../show_if_wrapper_component.rb | 40 ------------------- 2 files changed, 41 deletions(-) delete mode 100644 lib/view_component_contrib/show_if_wrapper_component.rb diff --git a/lib/view_component_contrib.rb b/lib/view_component_contrib.rb index c5b7bcc..2b20996 100644 --- a/lib/view_component_contrib.rb +++ b/lib/view_component_contrib.rb @@ -12,7 +12,6 @@ module ViewComponentContrib autoload :WrapperComponent, "view_component_contrib/wrapper_component" autoload :WrappedHelper, "view_component_contrib/wrapped_helper" autoload :WrappedInHelper, "view_component_contrib/wrapped_in_helper" - autoload :ShowIfWrapperComponent, "view_component_contrib/show_if_wrapper_component" autoload :StyleVariants, "view_component_contrib/style_variants" autoload :Base, "view_component_contrib/base" diff --git a/lib/view_component_contrib/show_if_wrapper_component.rb b/lib/view_component_contrib/show_if_wrapper_component.rb deleted file mode 100644 index 15d6230..0000000 --- a/lib/view_component_contrib/show_if_wrapper_component.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -module ViewComponentContrib - class ShowIfWrapperComponent < ViewComponent::Base - # ShowIfWrapperComponent will only render if at least one of the - # conditional fragments produces output. - # A conditional fragment is declared by wrapping markup in a - # `show_if` block. - - attr_reader :conditional_fragments - - def before_render - @captured_content = content - end - - def initialize - @conditional_fragments = [] - end - - def call - @captured_content if conditions_passed? - end - - # Call this inside the template: = w.show_if { … } - # Captures the block, pushes it into the list, and - # returns the HTML string so it appears inline where it was declared - def show_if(&block) - view_context.capture(&block).tap do |html| - conditional_fragments << html - end - end - - # Render the wrapper itself only if: - # - at least one conditional fragment produced output, OR - # - no conditional fragments were declared - def conditions_passed? - conditional_fragments.empty? || conditional_fragments.any?(&:present?) - end - end -end From 8b150e54aad89796b77ae96c11870bf819987ec0 Mon Sep 17 00:00:00 2001 From: Ollie James Date: Fri, 16 May 2025 11:26:56 +0100 Subject: [PATCH 12/20] Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f591df..c136e9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## master -- Add `ShowIfWrapperComponent` for wrappers that are conditional on multiple child components +- Add `#wrapped_in` helper to support wrappers that are conditional on multiple child components ## 0.2.4 (2025-01-03) From 8b25839952f24006a8266ccd9bc634a1bb0d6c15 Mon Sep 17 00:00:00 2001 From: Ollie James Date: Fri, 16 May 2025 11:32:16 +0100 Subject: [PATCH 13/20] Update readme --- README.md | 47 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d446ded..7ba11c0 100644 --- a/README.md +++ b/README.md @@ -815,25 +815,54 @@ For example, consider a container element with a title and two components. 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 a special `ViewComponentContrib::ShowIfWrapperComponent` class that wraps around the container. We can declare the conditional parts of the markup with the `.show_if` block. +We introduce the `#wrapped_in` method that can be used on children of 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::ShowIfWrapperComponent.new do |wrapper| %> +<%= render ViewComponentContrib::WrapperComponent.new do |wrapper| %>

Title

- <%= wrapper.show_if do %> - <%= render ExampleA::Component %> - <%- end -%> - <%= wrapper.show_if do %> - <%= render ExampleB::Component %> - <%- end -%> + <%= render ExampleA::Component.new.wrapped_in(wrapper) %> + <%= render ExampleB::Component.new.wrapped_in(wrapper) %>
<%- end -%> ``` -If nothing is rendered in _any_ of the blocks, the wrapper component doesn't render. Otherwise, if _at least one_ of the `.show_if` blocks renders something, all of the content within the wrapper component is rendered. +You can place any content inside a wrapper component. + +You can even nest wrapper components: + +```erb +<% # Will only render if at least one of the inner wrappers renders %> +<%= render ViewComponentContrib::WrapperComponent.new do |wrapper| %> +
+

Examples

+ + <% # Will only render if `FooExampleA` or `FooExampleB` renders %> + <%= 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 -%> + + <% # Will only render if `BarExampleA` or `BarExampleB` renders %> + <%= 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 -%> +``` ## License From 4bcc75d06fbf55f5cbc138458956c9c91a23f779 Mon Sep 17 00:00:00 2001 From: Ollie James Date: Fri, 16 May 2025 11:45:40 +0100 Subject: [PATCH 14/20] Add tests for errors --- .../wrapper_component.rb | 10 ++++- test/cases/wrapped_in_test.rb | 39 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/lib/view_component_contrib/wrapper_component.rb b/lib/view_component_contrib/wrapper_component.rb index f931681..ad2956d 100644 --- a/lib/view_component_contrib/wrapper_component.rb +++ b/lib/view_component_contrib/wrapper_component.rb @@ -14,6 +14,12 @@ def initialize(component) end end + class ComponentPresentError < StandardError + def initialize + super("A wrapper component cannot register a component if it already has a component from the constructor") + end + end + attr_reader :component_instance, :registered_components # We need to touch `content` before the `render?` method is called, @@ -21,7 +27,7 @@ def initialize(component) # This overrides the default lazy evaluation of `content` in ViewComponent, # but it's necessary for the wrapper to work properly. def before_render - content + content if component_instance.blank? end def initialize(component = nil) @@ -55,7 +61,7 @@ def component # 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 ArgumentError, "Wrapper already has a component" if component_instance.present? + raise ComponentPresentError if component_instance.present? raise ArgumentError, "Expected a ViewComponent" unless component.is_a?(ViewComponent::Base) registered_components << component diff --git a/test/cases/wrapped_in_test.rb b/test/cases/wrapped_in_test.rb index b7c23ff..04952d3 100644 --- a/test/cases/wrapped_in_test.rb +++ b/test/cases/wrapped_in_test.rb @@ -17,6 +17,27 @@ def call end end + class NonViewComponent + include ViewComponentContrib::WrappedInHelper + + def call + "Hello from test".html_safe + end + end + + def test_renders_when_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}
".html_safe + end + + assert_selector page, "h3", count: 1, text: "Title" + assert_selector page, "div", text: "Hello from test", count: 1 + end + def test_renders_when_two_inner_components_render inner_component_a = Component.new inner_component_b = Component.new @@ -123,4 +144,22 @@ def test_outer_wrapper_does_not_render_when_no_inner_wrappers_render 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 end From 37361dee04413d87f9d21016d916eee102c9885e Mon Sep 17 00:00:00 2001 From: Ollie James Date: Fri, 16 May 2025 14:58:12 +0100 Subject: [PATCH 15/20] Add include instructions to readme --- README.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7ba11c0..3ec5431 100644 --- a/README.md +++ b/README.md @@ -815,7 +815,7 @@ For example, consider a container element with a title and two components. 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 used on children of 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. +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| %> @@ -829,9 +829,17 @@ We introduce the `#wrapped_in` method that can be used on children of a `ViewCom <%- end -%> ``` -You can place any content inside a wrapper component. +To use the method, include the helper in your base ViewComponent class: -You can even nest wrapper components: +```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 <% # Will only render if at least one of the inner wrappers renders %> From 6a8a86da7aad1ecee9ac6c4714456fe9925dc720 Mon Sep 17 00:00:00 2001 From: Ollie James Date: Fri, 16 May 2025 15:49:44 +0100 Subject: [PATCH 16/20] Remove redundant test --- test/cases/wrapped_in_test.rb | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/test/cases/wrapped_in_test.rb b/test/cases/wrapped_in_test.rb index 04952d3..c510e66 100644 --- a/test/cases/wrapped_in_test.rb +++ b/test/cases/wrapped_in_test.rb @@ -25,19 +25,6 @@ def call end end - def test_renders_when_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}
".html_safe - end - - assert_selector page, "h3", count: 1, text: "Title" - assert_selector page, "div", text: "Hello from test", count: 1 - end - def test_renders_when_two_inner_components_render inner_component_a = Component.new inner_component_b = Component.new From c97da3eedf20301e02e82f5dcf2773b12e28630f Mon Sep 17 00:00:00 2001 From: Ollie James Date: Fri, 16 May 2025 16:10:37 +0100 Subject: [PATCH 17/20] Fix comment syntax in readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3ec5431..e587431 100644 --- a/README.md +++ b/README.md @@ -842,12 +842,12 @@ end You can place any content inside a wrapper component. You can even nest wrapper components: ```erb -<% # Will only render if at least one of the inner wrappers renders %> + <%= render ViewComponentContrib::WrapperComponent.new do |wrapper| %>

Examples

- <% # Will only render if `FooExampleA` or `FooExampleB` renders %> + <%= render ViewComponentContrib::WrapperComponent.new.wrapped_in(wrapper) do |foo_wrapper| %>

Foo Examples

@@ -858,7 +858,7 @@ You can place any content inside a wrapper component. You can even nest wrapper
<%- end -%> - <% # Will only render if `BarExampleA` or `BarExampleB` renders %> + <%= render ViewComponentContrib::WrapperComponent.new.wrapped_in(wrapper) do |bar_wrapper| %>

Bar Examples

From 3357e5f707a6757a150b97ed25fbb86b38818873 Mon Sep 17 00:00:00 2001 From: Ollie James Date: Fri, 16 May 2025 16:10:51 +0100 Subject: [PATCH 18/20] Add `fallback` method --- README.md | 19 +++++++++++ .../wrapper_component.rb | 32 +++++++++++++++++-- test/cases/wrapped_in_test.rb | 30 +++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e587431..ca302e6 100644 --- a/README.md +++ b/README.md @@ -872,6 +872,25 @@ You can place any content inside a wrapper component. You can even nest wrapper <%- end -%> ``` +You can also use the `#fallback` 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.fallback do -%> + Examples coming soon! + <%- end -%> +<%- end -%> +``` + ## License The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). diff --git a/lib/view_component_contrib/wrapper_component.rb b/lib/view_component_contrib/wrapper_component.rb index ad2956d..78de9d6 100644 --- a/lib/view_component_contrib/wrapper_component.rb +++ b/lib/view_component_contrib/wrapper_component.rb @@ -20,7 +20,7 @@ def initialize end end - attr_reader :component_instance, :registered_components + attr_reader :component_instance, :registered_components, :fallback_block # We need to touch `content` before the `render?` method is called, # otherwise children calling `.wrapped_in` won't be registered. @@ -37,14 +37,17 @@ def initialize(component = nil) def render? return component_instance.render? if component_instance.present? + return true if render_from_registered_components? - registered_components.any?(&:render?) + @fallback_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(&@fallback_block) if render_fallback? + content end @@ -66,5 +69,30 @@ def register(component) registered_components << component end + + # Register a fallback block: `wrapper.fallback { "Nothing to show" }` + # The block is only emitted when: + # - no component instance was supplied, AND + # - every registered component’s `render?` returns false + def fallback(&block) + raise ArgumentError, "Block required" unless block + + @fallback_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_fallback? + @fallback_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 index c510e66..658c40e 100644 --- a/test/cases/wrapped_in_test.rb +++ b/test/cases/wrapped_in_test.rb @@ -149,4 +149,34 @@ def test_raises_error_when_passing_non_view_component non_view_component.wrapped_in(wrapper_component) end end + + def test_fallback_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.fallback { "
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_fallback_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.fallback { "
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 From 6a381f32e2885dfe6d40443be62a9e947eb4799d Mon Sep 17 00:00:00 2001 From: Ollie James Date: Fri, 16 May 2025 18:43:35 +0100 Subject: [PATCH 19/20] Rename `fallback` to `placeholder` --- README.md | 4 ++-- lib/view_component_contrib/wrapper_component.rb | 16 ++++++++-------- test/cases/wrapped_in_test.rb | 8 ++++---- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index ca302e6..98770d8 100644 --- a/README.md +++ b/README.md @@ -872,7 +872,7 @@ You can place any content inside a wrapper component. You can even nest wrapper <%- end -%> ``` -You can also use the `#fallback` method on a wrapper component to render a block _only_ if none of the registered components render. +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| %> @@ -885,7 +885,7 @@ You can also use the `#fallback` method on a wrapper component to render a block
- <%- wrapper.fallback do -%> + <%- wrapper.placeholder do -%> Examples coming soon! <%- end -%> <%- end -%> diff --git a/lib/view_component_contrib/wrapper_component.rb b/lib/view_component_contrib/wrapper_component.rb index 78de9d6..9e7eb02 100644 --- a/lib/view_component_contrib/wrapper_component.rb +++ b/lib/view_component_contrib/wrapper_component.rb @@ -20,7 +20,7 @@ def initialize end end - attr_reader :component_instance, :registered_components, :fallback_block + 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. @@ -39,14 +39,14 @@ def render? return component_instance.render? if component_instance.present? return true if render_from_registered_components? - @fallback_block.present? + @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(&@fallback_block) if render_fallback? + return view_context.capture(&@placeholder_block) if render_placeholder? content end @@ -70,14 +70,14 @@ def register(component) registered_components << component end - # Register a fallback block: `wrapper.fallback { "Nothing to show" }` + # 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 fallback(&block) + def placeholder(&block) raise ArgumentError, "Block required" unless block - @fallback_block = block + @placeholder_block = block nil end @@ -89,8 +89,8 @@ def render_from_registered_components? @render_from_registered_components ||= registered_components.any?(&:render?) end - def render_fallback? - @fallback_block.present? && + def render_placeholder? + @placeholder_block.present? && component_instance.blank? && !render_from_registered_components? end diff --git a/test/cases/wrapped_in_test.rb b/test/cases/wrapped_in_test.rb index 658c40e..5272961 100644 --- a/test/cases/wrapped_in_test.rb +++ b/test/cases/wrapped_in_test.rb @@ -150,14 +150,14 @@ def test_raises_error_when_passing_non_view_component end end - def test_fallback_does_not_render_when_any_inner_component_renders + 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.fallback { "
Nothing to show
".html_safe }}".html_safe + "#{wrapper.placeholder { "
Nothing to show
".html_safe }}".html_safe end assert_selector page, "h3", count: 1, text: "Title" @@ -165,14 +165,14 @@ def test_fallback_does_not_render_when_any_inner_component_renders assert_no_selector page, "div", text: "Nothing to show" end - def test_fallback_renders_when_no_inner_component_renders + 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.fallback { "
Nothing to show
".html_safe }}".html_safe + "#{wrapper.placeholder { "
Nothing to show
".html_safe }}".html_safe end assert_no_selector page, "h3", count: 1, text: "Title" From 60840c211ce5f72cbbb074e0437b14629a10a323 Mon Sep 17 00:00:00 2001 From: Ollie James Date: Fri, 16 May 2025 18:45:35 +0100 Subject: [PATCH 20/20] Include placeholder method in changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c136e9c..326e7da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 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.4 (2025-01-03)