-
Notifications
You must be signed in to change notification settings - Fork 487
Add support for slot arguments #2540
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
joelhawksley
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@CyberAP thank you for taking the time to write and document your proposal here. I think it's an interesting approach and I have some thoughts about what I'd want to see change before considering it for inclusion in the framework.
Real-world use-case
Can you provide an example of how you're looking to use this feature? Specifically, I'm looking for enough context to know what is being blocked by this PR so that I can understand if this proposed solution is the best option.
Explicit argument definition
The proposed implementation does not define what arguments can be passed to a slot, instead allowing callers to pass whatever they want! One of ViewComponent's key advantages is clearly communicating what context is needed to render views. What might it look like to define parameters when registering a slot?
|
Thanks for taking your time to look into this @joelhawksley!
For sure! I am going to use more abstract components than in the real world to remove the project context from this. I am also going to use HAML since that's how I write my components and I am more familiar with it than with erb. Hope that still can help you evaluate this. Let's say I have a TableComponent, which renders multiple TableRowComponents through configuration: -# BaseAppComponent
= render TableComponent.new(rows: rows)-# TableComponent
%table
%thead
-# There could be other configurable content
%tbody
- rows.each do |row|
= render TableRowComponent.new(row: row)-# TableRowComponent
%tr
- row.columns.each do |column|
%td= column.textThis is a simple and good enough abstraction to render the tables. But now I need to customize some of the rows and insert content in between them: add edit buttons or show a warning message. I am left with only 2 options:
Option 1 makes the component aware of all the possible content that could go inside a table cell. If I needed to support other types of content I would have to modify the TableRowComponent implementation. This makes component less reusable and harder to work with: -# TableRowComponent
%tr
- row.columns.each do |column|
- if column.warning
%td
= render WarningComponent.new(warning: column.warning)
- elsif column.button
%td
= render ButtonComponent.new(**column.button)
- else
%td= column.textOption 2 is less secure because now we're inserting HTML, rather than working with Rails rendering. For my particular case I'd like to avoid relying on Now let's imagine we had slot arguments. The implementation would change to this: -# BaseAppComponent
= render TableComponent.new(rows: rows) do |instance|
- instance.with_after_row do |row, row_index|
- if row_index == row_with_button_index
%tr
%td{ colspan: 2 }
= render ButtonComponent.new(**button_options)
- if row_index == row_with_warning_index
%tr
%td{ colspan: 2 }
= render WarningComponent.new(warning: warning) -# TableComponent
%table
%thead
-# There could be other configurable content
%tbody
- rows.each_with_index do |row, row_index|
= render TableRowComponent.new(row: row)
= after_row(row, row_index)Now our TableComponent can be reused anywhere and we can insert any type of content in between rows, without TableComponent and TableRowComponent being aware which types of content they can support.
That's a very good point. WDYT of adding named arguments to renders_one :after_row, with_arguments: [:row, :row_index] # for ordered arguments
renders_one :header, with_arguments: { :text => true } # for named arguments, true means requiredI am not against even limiting this for now to just named arguments only. This matches how Vue handles slots for example and would still work for my case as well. Happy to hear your thoughts about this. |
|
@joelhawksley I believe this was closed by accident. |
|
@CyberAP it was, my apologies! |
joelhawksley
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@CyberAP thank you for the detailed response ❤️
I had wondered if you were trying to build a table component 😄 Tables have long been a source of discussion here on the project and everyone seems to have a different opinion on how to build them! Believe it or not, I do not have an opinion, as I have yet to have to build a TableComponent myself 😅
That being said, I forgot that we already accept arguments for slots that render other components: https://viewcomponent.org/guide/slots.html#component-slots. Have you considered leveraging that API? What I like about that approach is that 1) it already exists! and 2) it allows for defining arguments the standard Ruby way instead of with a custom format like you suggested.
@joelhawksley indeed I did! That was actually the first thing I tried but it's actually the opposite of what I want. It allows you to pass data to a slot. But I want to pass data from a slot to another component or logic. This is a crucial difference: passing data from a slot allows us to decouple components. With component slots our host component becomes aware of the component that renders a slot. If I wanted a slot to render different components I would have to describe all the possible variants that a slot can render using lambda slots. For me personally that's no different from passing another argument to the component itself and configuring the display this way. I've tried to pass a block to a lambda slot but that didn't work out for me unfortunately. Slot arguments invert this paradigm: now the host component does not control how and what is rendered in the slot, it's the receiving component that uses that slot and controls what to render inside of it. |
What are you trying to accomplish?
This PR adds support for slot arguments.
Returning:
What approach did you choose and why?
Slot arguments provide 2 major benefits to a component approach:
Right now ViewComponent only supports basic slots without arguments. This means you have to follow patterns like this in order to get the data you need for rendering:
This breaks one of the key component principles: Encapsulation. If the parent component knows about internal state of the child component they become coupled. That leads to unreliable refactorings and degraded component's reusability.
A common pattern of customizing a slot would be to use lambda slots:
Unfortunately this approach does not allow us to pass data to the block directly, meaning it's actually the inverse of what we want: it passes data to a child component, not the child component passing data to the parent.
Another problem with this approach is that we can't customize what exactly we render inside the lambda.
Slot arguments allow us to solve these problems with elegance. Instead of component instance we access slot arguments passed to the block:
This means we are in full control of what's rendered inside the slot and also never touch component internals, allowing for safer refactorings.
With slot arguments we can now support more advanced cases of using slots:
Anything you want to highlight for special attention from reviewers?
This feature has been previously discussed but there still is no working alternative to it.
This approach is not new and is inspired by Scoped Slots in Vue and Child Component as Function pattern in React.