ViewComponent / view_component

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

Rendering a collection with dynamically-chosen components #1231

Open boardfish opened 2 years ago

boardfish commented 2 years ago

Feature request

I've just finished talking with @coder2000 about this. They were trying to render a collection, rendering different components based on another argument. So their aim was for User::Component to render a collection of User::Actives, for example. It's got me thinking about how collections would work with dynamically chosen components — that seemed to be their original aim as they've mentioned in a discussion. There's been some talk about rendering the same component with different templates in the past, which I've done some work to try and establish patterns for, but this was a nice reminder that it still sort of needs to be thought about for collections.

Let's say you're rendering a list of user profiles, some of which you'd like to show in less detail because they're incomplete, not visible, or otherwise. One way you could do that is to render FullProfileComponents, and render NullProfileComponents for those that aren't present. I think it would follow that you should be able to create a 'factory component' like this:

class ProfileComponent < ApplicationComponent
  with_collection_parameter :profile

  def self.new(profile:)
    (profile.complete? ? FullProfileComponent : NullProfileComponent).new(profile: profile)
  end
end
<%= render ProfileComponent.with_collection(@profiles) # renders `Full` and `NullProfileComponent` on a case-by-case basis %>

The only thing stopping this at present is validate_collection_parameter, which should check .new's parameters as well as #initialize. I wanted to open up discussion before making any changes - what do folks think about this as a pattern?

joelhawksley commented 2 years ago

@boardfish interesting. For the sake of comparison, can you provide example code for how you would accomplish this using partials?

boardfish commented 2 years ago

You could do something like this:

<%# app/views/profiles/_profile.html.erb %>
<%= render (profile.complete? ? 'full_profile' : 'null_profile'), profile: profile %>
<%# app/views/profiles/index.html.erb %>
<%= render @profiles %>

I guess it's also achievable this way:

<%# app/views/profiles/index.html.erb %>
<% @profiles.each do |profile| %>
  <%= render (profile.complete? ? 'full_profile' : 'null_profile')
<% end %>

I feel like components that entirely delegate off to other components like this based on their input could be quite an intuitive pattern, and may address a lot of the calls for a component being able to render multiple different templates.

joelhawksley commented 2 years ago

render @profiles

That's the case I'd be most interested in supporting 👍🏻

boardfish commented 2 years ago

Yeah, I think it really clearly marks the benefit of this as a feature. 👍

render @profiles currently makes use of to_partial_path as I understand it, so the implementation is directly linked to partials. I suppose what would be nice is for collection rendering to be able to use render_in somehow... A little bit vague right now, but I might do a bit of digging soon to try and understand how best this could fit into ViewComponent and/or Rails.

Spone commented 1 year ago

Maybe we could mirror #to_partial_path with a convention to define a #to_component method on each item. This method would specify which component to use when rendering the item as part of a collection.

boardfish commented 1 year ago

That's a great idea! So am I right in saying you're suggesting, for example:

class Person
  def to_component
    profile_complete? ? FullProfileComponent.new(self) : NullProfileComponent.new(self)
  end
end

And it follows that:

<%= render @people %>

would call to_component on each Person to get the instances, right?

I wonder if there's a way to work slots into this, but I like where this is going.

Spone commented 1 year ago

Yes, that could be the idea. Thanks for taking the time to write the code samples :)

Here is a real world example:

# app/models/sections/slate.rb
class Sections::Slate < Sections::Base
  # ...

  def to_component
    Sections::SlateComponent.new(
      title:,
      text:,
      image: image_url,
      button_text:,
      button_link: button_linkable,
      template:,
    )
  end

  def to_admin_component
    Admin::Sections::SlateComponent.new(
      section: self,
    )
  end

Here's the front-office view:

<%# app/views/pages/home.html.erb %>

<% @page.sections.each do |section| %>
  <%= render section.to_component %>
<% end %>

And I'm using render section.to_admin_component in the back-office to render the component containing the fieldset to edit the section.

This example does not use slots but could serve as a starting point for thinking about how slots could work, for instance button_text and button_link could be good candidates for refactoring with a button slot.