ViewComponent / view_component

A framework for building reusable, testable & encapsulated view components in Ruby on Rails.
https://viewcomponent.org
MIT License
3.24k stars 415 forks source link

referencing slot component predicate methods #2059

Open mosaaleb opened 1 month ago

mosaaleb commented 1 month ago

I have a ToolbarComponent that renders a single RansackerComponent, and the RansackerComponent renders multiple FilterComponent slots. Here's a simplified version of the setup:

# toolbar_component.rb
class ToolbarComponent < ViewComponent::Base
  renders_one :ransacker, Ransack::RansackerComponent
end

# ransacker_component.rb
module Ransack
  class RansackerComponent < ViewComponent::Base
    renders_many :filters, ->(field:, select_options:, prompt:, checkbox: false) do
      Ransack::FilterComponent.new(
        field: field,
        ransack_param: param,
        ransack_form_id: id,
        select_options: select_options,
        prompt: prompt,
        checkbox: checkbox,
      )
    end
  end
end

In the view, the components are used like this:

# In views
<%= render ToolbarComponent.new do |toolbar| %>
  <% toolbar.with_ransacker(query: query, url: url) do |ransacker| %>
    <% ransacker.with_filter(field: field, select_options: []) %>
  <% end %>
<% end %>

And the ToolbarComponent template looks like this:

# toolbar_component.html.erb

<%= ransacker if ransacker? %> <!-- order 1 -->

<% if ransacker&.filters? %> <!-- order 2 -->
  <div class="flex gap-4">
    <% ransacker.filters.each do |filter| %>
      <%= filter %>
    <% end %>
  </div>
<% end %>

The ransacker component renders a ransack search form, and the rendering of the filters is delegated to be rendered within the toolbar component.

Problem

If I swap the order of the components (i.e., checking for ransacker.filters? before rendering ransacker), nothing gets rendered at all.

Questions

Steps to reproduce

Expected behavior

The ToolbarComponent should conditionally render the RansackerComponent and its FilterComponent slots. When the order of checking and rendering components is swapped, the components should still be rendered correctly.

Actual behavior

When the order of checking for ransacker.filters? and rendering ransacker is swapped, nothing gets rendered at all. It seems that the conditional checks on slots like ransacker&.filters? might not be working as expected.

System configuration

Rails version: 7.0.4 Ruby version: 3.1.0 Gem version: 3.0.0

Any insights or recommendations would be greatly appreciated!

boardfish commented 1 month ago

Hey @mosaaleb, thanks for opening an issue with us! I think this happens because the block passed to with_ransacker isn't called until the ransacker slot is rendered. I don't know if there's a way to figure this out so that we can raise or warn if folks try to access slots in advance of that, but it might be a good thing to add if we can.

This behaviour isn't obvious, but I think it could signal some changes you may want to make to your components.

Looking at this, I would expect that this code indicates the hierarchy of components here:

<%= render ToolbarComponent.new do |toolbar| %>
  <% toolbar.with_ransacker(query: query, url: url) do |ransacker| %>
    <% ransacker.with_filter(field: field, select_options: []) %>
  <% end %>
<% end %>

That is, I'd expect the template for ToolbarComponent to render RansackerComponent, and I'd expect the template for RansackerComponent to be rendering FilterComponents. In this code:

# toolbar_component.html.erb

<%= ransacker if ransacker? %> <!-- order 1 -->

<% if ransacker&.filters? %> <!-- order 2 -->
  <div class="flex gap-4">
    <% ransacker.filters.each do |filter| %>
      <%= filter %>
    <% end %>
  </div>
<% end %>

...ToolbarComponent is reaching down into the RansackerComponent's FilterComponents to render them. So I would first consider the structure of these components – if ToolbarComponent needs to do something based on the filters, perhaps consider passing them into it as data and then using said data in a lambda slot, e.g.:

# ToolbarComponent.new([{ field: field, select_options: []}, {...}])
class ToolbarComponent < ApplicationComponent
  def initialize(filters)
    @filters = filters
  end

  renders_one :ransacker, lambda do |**kwargs|
    RansackerComponent.new(**kwargs)
      .tap do |c|
        @filters.each { |filter_args| c.with_filter(**filter_args) }
      end
    end
  end
end
reeganviljoen commented 1 month ago

@mosaaleb does the above work for you or is it still an issue