Conversation
|
Thanks for opening this! I think your first two examples are possible today. For the table example, I think there's some good discussion there. Making a table with slots v2 should be possible, and have a reasonable API, not saying that it necessarily does since we haven't tried yet, but it should be something we support. One thing that stands out is that the table accepts a Here's an example of how I think a table component could be created, while giving slots access to the parent state they need: class MyTableComponent < ViewComponent::Base
include ViewComponent::SlotableV2
attr_reader :columns
renders_many :rows, -> (data) do
content_tag :tr do
safe_join(columns.map do |column| # Because lambda slots are evaluated in the parent context, columns should be available
content_tag :td do
data.public_send(column)
end
end)
end
end
def initialize(columns:)
@columns = columns
end
endThis component's template could look like: <%# my_table_component.html.erb %>
<table>
<thead>
<%= columns.each do |column| %>
<th><%= column %></th>
<% end %>
</thead>
<tbody>
<%= rows.each do |row| %>
<%= row # Renders our row slot %>
<% end %>
</tbody>
</table>Then usage could look like: class Post
attr_reader :title, :body
def initialize(title:, body:)
@title = title
@body = body
end
end
posts = Post.all # Pretend this returns 3 posts
render MyTableComponent.new(columns: [:title, :body]) do |c|
posts.each do |post|
c.row(post)
end
endIt's possible I don't completely understand the iterator example, so let me know if that's the case or if the example above clarifies anything. |
|
It's also possible to replace the This uses the exact same public API as above, so I'll only share the parts that changed: ```ruby
class MyTableComponent < ViewComponent::Base
include ViewComponent::SlotableV2
attr_reader :columns
renders_many :rows, -> (data) do
MyRowComponent.new(data, columns: columns) # columns is available because lamda is evaluated in parent component scope
end
def initialize(columns:)
@columns = columns
end
class MyRowComponent < ViewComponent::Base
def initialize(data, columns:)
@data = data
@columns = columns
end
end
end<%# my_row_component.html.erb %>
<tr>
<% columns.each do |column| %>
<td><%= @data.public_send(column) %></td>
<% end %>
</tr> |
You mean, defining public accessors on the component itself will achieve the desired result? Nice :)
That is where I think it's not really dev-friendly. Almost all tables will end up rendering each one row for each element in data, but every table can have wildly different cell "ERB snippets". A more likely example would be the following:
So something like this: table = Table.new(data: data) do |t|
t.column :foo, sortable: true, searchable: true, label: "Foo for real"
t.column [:bar, :baz], label: "Uses `dig` into object/hash etc"
t.column :quz do |elm|
helpers.tag.b elm[:quz]
end
endwould do (I have it actually working like this in a POC repo, inside an ERB tag, but _passing the block to new, not to render). Ideally, I would not "build" the whole render mini-blocks for each column, because in complex cases one has to use <%= render Table.new(data.data) do |t| %>
<% t.column :foo ..... %>
<% t.column [:bar, :baz] ..... %>
<% t.column :quz do |elm| %>
<b><%= elm.quz %></b>
<i><small><%= elm.quz_extra_text %></small></i>
<% end %>
<% end %> |
|
My previous example is not much different from: <%= render Table.new(params: params) do |t| %>
<!-- this goes in thead -->
<% t.th :foo ..... %>
<% t.th :bar_baz ..... %>
<% t.th :quz %>
<!-- this goes in tbody -->
<%= data.each do |elm| %>
<% t.row do %>
<td><%= elm.foo %> </td>
<td><%= elm.bar.baz %> </td>
<td>
<b><%= elm.quz %></b>
<i><small><%= elm.quz_extra_text %></small></i>
</td>
<% end %>
<% end %>
<% end %>which is attainable with the current code. The issue with this implementation is that the column order + identity is in two places so it is very likely that they would go out of sync. |
I think this example would mostly work, except e.g. <% t.column :quz do |component| %>
<b><%= component.quz %></b>
<i><small><%= component.elm.quz_extra_text %></small></i>
<% end %> |
|
Ok I managed to achieve it, but only with a hack. I can explain, and maybe there is an easier solution I'm not seeing. The issue is that instance variables are set on the child component once for all, as soon as the slot is instantiated. So there is no way to "inject" the item (elm) in the column components, unless it's made publicly writable on the column component: # component class
class UI::Table2 < ViewComponent::Base
include ViewComponent::SlotableV2
attr_reader :variant, :data
renders_many :columns, 'ColumnComponent'
def initialize(data:)
@data = data
end
def columns_with(item)
# on each iteration of data.each, we replace the item on the component instance
columns.each { |slot| slot.instance_variable_get(:@_component_instance).item = item }
end
class ColumnComponent < ViewComponent::Base
attr_accessor :item
attr_reader :name, :blk
def initialize(name:, item: nil, **)
@item = item
@name = [*name]
super
end
def value
@item.dig(*@name)
end
def call
# content if it had a block, value otherwise (taken from the name (dig)
content || value
end
end
end<!-- component template -->
<table>
<thead>
<tr>
<% columns.each do |column| %>
<th>
<%= token_list(column.name).humanize %>
</th>
<% end %>
</tr>
</thead>
<tbody>
<% data.each do |item| %>
<tr>
<% columns_with(item).each do |column| %>
<td>
<%= column %>
</td>
<% end %>
</tr>
<% end %>
</tbody>
</table><!-- usage -->
<% data = [
{foo: "foo-1", bar: {baz: "bar-1"}},
{foo: "foo-2", bar: {baz: "bar-2"}}
] %>
<%= render UI::Table2.new(data: data) do |t| %>
<!-- just dig the data item -->
<% t.column name: :foo %>
<!-- the result of digging is exposed as `value` -->
<% t.column name: :bar do |c| %>
<%= c.value[:baz] %>
<% end %>
<!-- the original item is exposed as `item` -->
<%= t.column name: [:bar, :baz] do |c| %>
<b><%= c.item[:bar][:baz] %>
<% end %>
<!-- no need to exit ruby too -->
<%= t.column name: [:bar, :baz] { |c| ui.button label: c.value } %>
<% end %>As said it "kind of" works but it requires modifying the state of each component, which is not a good thing. |
|
Ideally, where I have <% columns_with(item).each do |column| %>
<td>
<%= column %>
</td>
<% end %>I would like to have (officially supported) <% columns.each do |column| %>
<td>
<%= column.with(item: item) %>
</td>
<% end %>and that would do all the work of instantiating another component with overridden values (instead of reusing the same component and overwriting the state every time) If you agree with the idea, I can proceed to implement |
a123898 to
093e156
Compare
d475785 to
54eccc6
Compare
|
@rewritten fwiw I wound up here because I was trying to build a table component with an API very similar to yours and ran into this exact same problem. So it's not just you that thinks this is how a table component should work |
|
Closing as stale. Please reopen if you'd like to continue your work here ❤️ |
Summary
Provide a way to inject slot arguments from the parent component. It is useful for components that accept a slot that acts as a "template" which should be applied to different data (paginators, tables, ...).
A method
withis added toSlotV2, that returns a new slot instance with the arguments towithare merged with the original slot arguments.Example