view_component-contrib icon indicating copy to clipboard operation
view_component-contrib copied to clipboard

Feature: Extend `WrapperComponent` to support it being conditional on multiple components

Open osjjames opened this issue 8 months ago β€’ 6 comments

What is the purpose of this pull request?

Fixes #56.

What changes did you make? (overview)

#wrapped_in method

Extends the ViewComponentContrib::WrapperComponent to support many dependent child components, such that if none of the registered child components return true from their render? method, the wrapper component will also not render.

This is achieved through a helper method for components called #wrapped_in.

For example, consider here that we only want the content to be rendered if either ExampleA or ExampleB is rendered.

<%= render ViewComponentContrib::WrapperComponent.new do |wrapper| %>
  <div class="flex flex-col gap-4">
    <h3>Title</h3>
    <div class="flex gap-2">
      <%= render ExampleA::Component.new.wrapped_in(wrapper) %>
      <%= render ExampleB::Component.new.wrapped_in(wrapper) %>
    </div>
  </div>
<%- end -%>

It also supports deep nesting of conditional wrappers.

Imagine we have a large section called "Examples", with two sub-sections: "Foo Examples" and "Bar Examples". Each sub-section should only render if it has at least one component rendered inside it, and the large section should only render if at least one sub-section is rendered. We can achieve the behaviour like this:

<!-- Will only render if at least one of the inner wrappers renders -->
<%= render ViewComponentContrib::WrapperComponent.new do |wrapper| %>
  <div class="flex flex-col gap-4">
    <h3>Examples</h3>

    <!-- Will only render if `FooExampleA` or `FooExampleB` renders -->
    <%= render ViewComponentContrib::WrapperComponent.new.wrapped_in(wrapper) do |foo_wrapper| %>
      <div class="flex flex-col gap-4">
        <h4>Foo Examples</h4>
        <div class="flex gap-2">
          <%= render FooExampleA::Component.new.wrapped_in(foo_wrapper) %>
          <%= render FooExampleB::Component.new.wrapped_in(foo_wrapper) %>
        </div>
      </div>
    <%- end -%>

    <!-- Will only render if `BarExampleA` or `BarExampleB` renders -->
    <%= render ViewComponentContrib::WrapperComponent.new.wrapped_in(wrapper) do |bar_wrapper| %>
      <div class="flex flex-col gap-4">
        <h4>Bar Examples</h4>
        <div class="flex gap-2">
          <%= render BarExampleA::Component.new.wrapped_in(bar_wrapper) %>
          <%= render BarExampleB::Component.new.wrapped_in(bar_wrapper) %>
        </div>
      </div>
    <%- end -%>
  </div>
<%- end -%>

#placeholder method

Adds a public method to the ViewComponentContrib::WrapperComponent that allows some placeholder content to be rendered only if none of the wrapper's registered components render.

<%= render ViewComponentContrib::WrapperComponent.new do |wrapper| %>
  <div class="flex flex-col gap-4">
    <h3>Title</h3>
    <div class="flex gap-2">
      <%= render ExampleA::Component.new.wrapped_in(wrapper) %>
      <%= render ExampleB::Component.new.wrapped_in(wrapper) %>
    </div>
  </div>

  <!-- Will only render if neither `ExampleA` nor `ExampleB` render -->
  <%- wrapper.placeholder do -%>
    <span>Examples coming soon!</span>
  <%- end -%>
<%- end -%>

This furthers the goal of keeping conditionals out of the template, and utilises the recursive evaluation of render? methods.

How it works

When #wrapped_in is called on a component, the wrapper component stores it in an array called registered_components. The wrapper's #render? method then simply calls registered_components.any?(&:render?) and uses that result.

A consequence of this is that we lose the benefit of ViewComponent's lazy render evaluation. Child components need to be evaluated in order for #wrapped_in to be called, so a WrapperComponent must have all of its contents evaluated before render.

Is there anything you'd like reviewers to focus on?

  • Naming, particularly of public methods
  • Any simpler way to achieve placeholder functionality that I've missed?
  • Could this integrate any better with the existing behaviour of WrapperComponent? At the moment it has two "modes" that are mutually exclusive

Checklist

  • [x] I've added tests for this change
  • [x] I've added a Changelog entry
  • [x] I've updated a documentation

osjjames avatar Apr 25 '25 16:04 osjjames

Hey @osjjames,

Thanks for the proposal.

I like the idea and see how it can be helpful (thanks for the detailed examples). However, the ShowIf terminology/interface looks too verbose; I'd like to iterate and see if we can come up with something better.

Also, I think, we can still re-use the existing WrapperComponent.

Here is what I have in mind:

<%= render ViewComponentContrib::WrapperComponent.new do |wrapper| %>
  <div class="flex flex-col gap-4">
    <h3>Examples</h3>
    <div class="flex gap-2">
      <%= render ExampleA::Component.new.wrapped_in(wrapper) %>
      <%= render ExampleB::Component.new.wrapped_in(wrapper) %>
    </div>
  </div>
<%- end -%>

First, we don't pass any components to the WrapperComponent.newβ€”this way, we indicated the delayed registration of components under consideration.

Then, we introduced the #wrapped_in helper that just tracks the component and invokes their #render? method to see if we need to render anything.

One important difference here (and, I think, this is crucial) is that we don't rely on the HTML content but on the #render? callback, which is in line with the existing WrapperComponent.

WDYT?

palkan avatar May 09 '25 18:05 palkan

Hi @palkan, thanks for the review!

Yeah, the verbosity was something that I wasn't happy with either. Your suggested API is a big improvement!

I'll update this PR to reflect that. Should be done within a few days - I'll give you a shout if I'm stuck on anything πŸ‘

osjjames avatar May 13 '25 17:05 osjjames

Hi @palkan, ready for you to take another look πŸ™Œ

I've adopted the API you described, as well as adding a #fallback method to the WrapperComponent - it was something I required in my own project, so I've implemented it here. Happy to move that to a separate PR if you prefer.

osjjames avatar May 16 '25 15:05 osjjames

Looks great!

I'd only suggest renaming #fallback to #placeholder (use more UI-oriented language), and we're good to merge (well, docs and change logs would be helpful, too).

palkan avatar May 16 '25 17:05 palkan

Done πŸ‘Œ happy to do further docs/changelog updates if needed

osjjames avatar May 16 '25 17:05 osjjames

@palkan bump - anything else needed?

osjjames avatar May 21 '25 08:05 osjjames