Multiple calls to content-area #325
Replies: 9 comments 13 replies
-
I'm going to start from here:
@bbugh I hadn't thought through the use cases of multiple content-areas fully. I can see now why you're want to 'template' the items around them so as to not leak the details of the implementation. However, the combination fo suppling the multiple content-area plus arguments to class TimelineComponent < ViewComponent
attr_reader :items
# @param [Array<Hash>] items
def initialize(items:)
@items = items
end
end <!-- timeline_component.html.erb -->
<div class="timeline">
...
<% items.each_with_index do |item, index| %>
<div class="timeline-item">
<div class="timeline-icon">
<%= fa_icon(item[:icon]) %>
</div>
<div class="vertical-line"></div>
<div class="timeline-item-description"
data-target="timeline.item"
data-index="<%= index %>"
>
item[:description]
</div>
</div>
<% end %>
...
</div> Get's used line this: class ActivitiesTimelineComponent < ViewComponent
attr_reader :activities
def initialize(activities:)
@activities = activities
end
def timeline_items
@timeline_items ||= activities.map do |activity|
{
icon: activity.icon,
description: "#{activity.name} -#{time_ago_in_words(activity.created_at)}"
}
end
end <!-- activities_timeline_component.html.erb -->
<%= render(TimelineComponent.new(items: activities_timeline) %> It's not required to have the IMO the above setup is easier to grok and easier to test with a clear API on TimelineComponent where you hand in the items vs repeat calls to I suspect I'm missing some part of the use case I'd love to continue the conversation to nail it down. |
Beta Was this translation helpful? Give feedback.
-
Thanks for continuing the conversation @jonspalmer! It's hard to talk about this multi-content area issue separate from the other ones I was discussing, because they are all involved with solving the same root problem. The proposals are 1) The goal of these proposals is to solve a flaw in existing front end component libraries, which ViewComponent currently shares. That flaw is that there's no solution for when you need a tightly-coupled mini-component that doesn't deserve it's own separate file, testing, resources, etc. Because React and Vue don't support this, everything that that takes args or is part of a loop has to be a component, no matter how small and simple. In our big Vue app, we have a lot of components with 3 lines of HTML and a couple handfuls with a single line. It's a big waste of cognitive space, testing, and complexity. There's no reason at all to have these files to be standalone, separate components, except for a shortcoming in the library design. With a few small changes, ViewComponent can solve these problems which would simplify a LOT of code and REALLY help for large projects. Ruby's dynamic nature with blocks ought to make this easy. There's prior art for a Ruby version in I can think of three solutions to consider for this problem:
Currently, ViewComponent has implemented #1. We (and I believe some other commenters in the feedback issue) are proposing to also support #3. I hope that the above explanation gives enough context to get on the same page and discuss viable solutions. As for your suggestion about There are other limitations with that suggestion, like: What if the description should have HTML? What about reading other instance variables from the controller (like the current user)? What about using other components? What about third party libraries that offer reusable components, do you write custom wrapper components for every one of those too? Yikes! If that's not clear enough, here's an even more complex example that wouldn't work with the "child components have the data" idea. There's some extra information from the controller and it needs to render another component. <%= render(TimelineComponent.new) do |c| %>
<% c.with(:header) do %>
Recent Activity for <%= @project.name %>
<% end %>
<% @activities.each do |activity| %>
<% c.with(:item, icon: activity.icon) do %>
<!-- use a value from the controller -->
<span class="activity-user <%= 'highlight' if activity.user == current_user %>">
<%= activity.user.name %>
</span>
-
<span class="text-muted">
<!-- renders another component that has some javascript -->
<%= render(RelativeTimeComponent.new(activity.created_at, update_interval: 60.seconds)) %>
</span>
<% end %>
<% end %>
<% end %> <!-- timeline_component.html.erb -->
<div class="timeline" data-controller="timeline">
<div class="timeline-header">
<%= header %>
</div>
<!-- this is really simple! doesn't need to be another component! -->
<% items.each_with_index do |item, args, index| %>
<div class="timeline-item">
<div class="timeline-icon">
<%= fa_icon(args[:icon]) %>
</div>
<div class="vertical-line"></div>
<div class="activity-description" data-target="timeline.item" data-index="<%= index %>">
<%= item %>
</div>
</div>
<% end %>
</div> |
Beta Was this translation helpful? Give feedback.
-
@bbugh Got it super helpful context. I agree the various feature sets are interrelated. I see the need for a version of 3 that you're proposing. The need to render HTML drives us towards support it via some version of the Sidetrack commentAs a total aside the "reading other instance variables" is, IMO and experience, not a great pattern. I've learned too many times that putting logic in the view like this is fragile and hard to test. For example: class ActivitiesDecorator < SimpleDelegator
def initialize(activity, current_user)
super(activity)
@current_user = current_user
end
def active?
activity.user == @current_user
end
end and then somewhere when preparing the data I'd do decorated_activities = activities.map { |a| ActivitiesDecorator.new(a, current_user) and hand I digress.....back to the point at hand. What should the API be?We've shared some ideas about what the API might be and elemental_components has some ideas there too. However, there are a bunch of cases to consider that need careful examination. Design Goals
Use Cases
1. Provide the "content block" for an area - Today's APIThe existing API allows you to provide strings or html content for a content area. Because it was easy to support but not driven by any real world use cases there are actually three ways to provide content. class ModalComponent < ViewComponent::Base
with_content_areas :header, :body
end <!-- app/components/modal_component.html.erb -->
<div class="modal">
<div class="header"><%= header %></div>
<div class="body"><%= body %></div>
</div> content as constructor argument<%= render(ModalComponent.new, header: "Hello Jane") do |component| %>
<!-- body -->
...
<% end %> content as non-block
|
Beta Was this translation helpful? Give feedback.
-
@jonspalmer @bbugh thank you for digging into this question in such detail. I'm excited to see where we end up!
The only way I can think of this being palatable would be to allow for "internal" components, where an internal component is defined inside of another component, and thus can have its own initializer, which brings me to:
This is one reason I like the idea of using hashes for these arguments. It allows developers to use
Could we leverage Rails' built-in support for inflections to pull this off? I'd love to avoid the @jonspalmer I'm wondering if we should consider deprecating the non-block syntax for content areas. I don't know how useful it is, and if it was getting in the way of accomplishing our goals here I'd be OK with removing it. I'm also wondering if we'd want to consider removing the <%= render(TimelineComponent.new(items: activities) do |c| %>
<% c.header centered: true do %>
Header
<% end %>
<% c.section size: "large" do %>
Section 1
<% end %>
<% c.section size: "large" do %>
Section 2
<% end %>
<% c.footer do %>
Footer
<% end %>
<% end %>
example shamelessly adapted from https://github.com/jensljungblad/elemental_components If we went this route, we could implement this new API without worrying about supporting these changes in the @jensljungblad I'd love your input here, as you've clearly put a lot of thought into building a similar API for |
Beta Was this translation helpful? Give feedback.
-
This i my sense too. When I look at the examples I documented above I think "boy you know want looks a lot like passing a set of arguments and a block to a content area? Another ViewComponent".
I can buy that argument. 👍
I'm sure we can use the inflections for the implementation. The need for a static declaration might be
Yup I'm ok removing it. I think we can leave it in the
I like this. I'd need to look to see how elemental_components does it. Somehow we need to solve the name overload for the getter/setter of the area but its clearly solvable. I suspect one thing we have to give up to support all of this is the 'simple' version of retrieving the content area. I think we're going to end up with the components view always doing |
Beta Was this translation helpful? Give feedback.
-
I'm three novels behind! I better write another one... @jonspalmer I think you summed up the different scenarios and the patterns very well.
I personally prefer the
I was inspired by this and wrote a bunch about how it might work, then realized at the end that I don't think this solves one of the main weaknesses. In my opinion, one of the key goals of this issue is to avoid having a bunch of tiny child components like this, wherever you put them: class CardHeaderComponent < ViewComponent
def initialize(icon:)
@icon = icon
end
end <!-- card_header_component.html.erb -->
<header class="my:big list-of tailwind-classes">
<%= content || "Default header" %>
</header> We have a lot of these in our Vue client, and not one of them is useful outside of their parent class. React's solution is equally pointless, but you can at least put the all of the micro comonents into the same file as the parent: export const Header = ({icon, children}) =>
<header className={'tailwind classes ' + icon}>{ children }</header>
export const Card = (...) => ... So, unless I misunderstood and you meant something like "invisible ViewComponents used by the internal rendering system", I'm not sure how well that would work.
I think that's a cool idea too. What if they're actual methods on the component? class CardComponent < ViewComponent
content_areas :header, :footer
# OR defining them individually allows for present or future configuration options,
# which could be really useful.
content_area :item, type: :list, some_other_field_option: :maybe
# OR another method just for lists, if content areas never need configuration?
content_list :item
# you could override them if you need to provide for options
def item(icon: 'SCREAM')
# Q: does super call method_missing if parent doesn't implement?
super(icon: icon.downcase)
end
end <!-- using card as a consumer -->
<%= render(CardComponent.new) do |c| %>
<% c.header(icon: 'fas fa-calculator') do %>
This is going to be a great header
<% end %>
<% 1.upto(10) do |i| %>
<% c.item do |f| %>
<%= f.input_tag "favorite_fruit_#{i}" %>
<% end %>
<% end %>
<% end %> I hope this all made sense! My brain is tired from a lot of heavy system work the last few days. |
Beta Was this translation helpful? Give feedback.
-
@joelhawksley @jonspalmer Hi! I feel like I need to catch up on some reading here. But yes, it looks like you're going through almost the exact same thing I went through a couple of years ago. Asking the same questions and thinking about the same trade-offs :) I'm happy to explain my thought process and the API I ended up with. I'd be happy to have I'll try and reply later this week! In the meantime, https://github.com/jensljungblad/elemental_components#elements tries to sum up some of my thoughts on these topics. |
Beta Was this translation helpful? Give feedback.
-
There's obviously a lot going on in this discussion, so I'm just going to jump in. I'll explain a bit what I was inspired by, and how I arrived at the API. I'll try and answer some of the questions I've seen asked here throughout. I wish I had my notes for this project but they are unfortunately on the other side of the Atlantic. I'll do my best to remember :) InspirationsI've been a proponent for component based design for a long while, I usually like to link to http://bradfrost.com/ who I think did a lot of good work early on trying to push these ideas. Initially my idea for the Ruby library was to try and go beyond very generic libraries like React in favor of something a little bit more opinionated, making certain design concepts first-class citizens, if you will. It never really got there but you can see some remnants of those early ideas in the API. I've worked a lot with the http://getbem.com methodology, but also OOCSS and other more utility class approaches. The
(Originally my components were named blocks, and modifiers were first-class citizens. Eventually I opted for a more generic API.) I tend to think about two different types of components. I'm sure there are some good names out there but I ended up referring to them as domain components and building block components.
It's interesting to note that almost all of the complicated component needs, such as multiple content areas, repeating content areas etc, are for these building block components. This is another concept that I toyed with having as a first-class citizen. I see you've already talked a bit about my comments reg. React. So just to clarify, the thing I've seen ab(used) a lot in React with regards to building block components, is the following pattern: <Card>
<CardHeader>Hey!</CardHeader>
</Card> Basically turning what really is internal subcomponents into regular components. There is no difference between You cannot inject HTML content into a private subcomponent (or subcomponents) in React, in an elegant way. At least not that I've seen. Mind you I've been out of the front-end business for a couple of years so maybe it's gotten better :) This always bothered me and I feel Ruby/Rails should be able to do better. I actually really enjoyed the API I eventually ended up with, compared to what I had to work with in React-land. The API: how it evolved and how it works under the hoodIn the very first version, this is what the API looked like: class PanelComponent < Components::Component
attribute :header
attribute :body
end <% # app/components/panel/_panel.html.erb %>
<div class="Panel">
<div class="Panel-header">
<%= panel.header %>
</div>
<div class="Panel-body">
<%= panel.body %>
</div>
</div> <%= component :panel, header: "Header", body: "Body" %> <%= component :panel, header: "Header" do |attributes| %>
<% attributes[:body] = capture do %>
<ul>
<li>...</li>
</ul>
<% end %>
<% end %> As you can see I made no distinction between attributes/elements at this point, but left it up to the user to make use of the The next iteration changed things up a bit by yielding the component instead of the attributes hash: <%= component "panel" do |c| %>
<% c.header do %>
Captured header
<% end %>
<% end %> This is similar to how Rails constructs forms, using There was still no distinction between attributes/elements. The I toyed with different APIs for this, such as I knew I wanted to be able to pass attributes to these subcomponents though. This from the fact that it is a common pattern in CSS, BEM, Bootstrap etc to have modifiers both on the top level component and its subcomponents: <div class="card card--wide">
<div class="card__header card__header--large">
</div>
</div> So the question became how to pass these down. Now that I had the yielded component, it felt very natural to just pass them to that method: <%= component "panel", wide: true do |c| %>
<% c.header(large: true) do %>
Captured header
<% end %>
<% end %> This led to a problem though. There was now a difference between "simple" attributes and these new attributes that could in turn take attributes. Before all of these were valid: <%= component :panel, header: "Header", body: "Body" %>
<%= component :panel, header: "Header" do |c| %>
<% c.body = "Some body" %>
<% end %>
<%= component :panel, header: "Header" do |c| %>
<% c.body do %>
Captured body
<% end %>
<% end %> But because of the requirement that I wanted to pass arguments to these subsections, I had to rethink. The distinction between attributes and elements was born:
The API for declaring them was initially class Card < Components::Component
has_one :header
has_many :sections do
attribute :size
end
has_one :body
end This is actually very similar to the Alright, great. One thing that bugged me was that I still didn't support this very simple scenario: <%= component "alert" do %>
Some content, without an element
<% end %> The reason for that being that I didn't have the concept of content blocks for the top level component. It always yielded the component itself to the block, and you had to use it to assign content: <%= component "alert" do |c| %>
<% c.body do %>
Some content, without an element
<% end %>
<% end %> The solution to the problem was the same thing @jonspalmer observed:
Which is pretty much exactly what I did: class Element
has_attributes
has_elements
has_content
end
class Component < Element
def render; end
end This turned the top level component into an element, meaning it had attributes + elements + a single content variable, and this was now possible: <%= component "alert" do %>
Some content, without an element
<% end %> It also meant elements could be nested. I don't necessarily think that is a good design practice (two levels maybe, three is getting silly) but it made the API neat and consistent. I glossed over repeating elements, which was happening in parallel. The need came from a few of our design system components. Yet again it was the building block components that wanted special treatment... We had several components which could contain one or many Questions
Correct, elements are objects with defined attributes. I too experimented with hashes instead of objects: jensljungblad/elemental_components#20, jensljungblad/elemental_components#19. I toyed with the idea of precompiling everything (helper methods included) into a hash and pass that to the template. That would have allowed completely different rendering systems as well. The designers I worked with tended to prefer
I think I answered this above, let me know if it's still unclear.
I actually had this going for the longest time, by leveraging the fact that Sorry for adding another novel to the list! |
Beta Was this translation helpful? Give feedback.
-
After playing with a bunch of different solutions this weekend, I came to the conclusion that it probably will require a DSL. I imagine I'm just re-treading the same path that @jensljungblad already tread. 🤪 |
Beta Was this translation helpful? Give feedback.
-
In #15 we got into a detailed discussion of the use cases for multiple calls to content areas. Breaking that conversation off into a separate issue so we can continue the discussion.
Prior conversation:
#15 (comment)
#15 (comment)
#15 (comment)
Beta Was this translation helpful? Give feedback.
All reactions