Skip to content

Contextual components#553

Closed
rewritten wants to merge 1 commit intoViewComponent:mainfrom
rewritten:contextual-components
Closed

Contextual components#553
rewritten wants to merge 1 commit intoViewComponent:mainfrom
rewritten:contextual-components

Conversation

@rewritten
Copy link

@rewritten rewritten commented Dec 14, 2020

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 with is added to SlotV2, that returns a new slot instance with the arguments to with are merged with the original slot arguments.

Example

class PaginationComponent < ViewComponent::Base
  include ViewComponent::SlotableV2

  renders_one :page, "PageComponent"
  renders_one :current_page, "PageComponent"

  def initialize(current:, max:)
    @current, @max = current, max
  end

  class PageComponent < ViewComponent::Base
    attr_reader :number

    def initalize(number: nil)
      @number = number
    end

    def call
      tag.li content, data: {page: number}
    end
  end
end
<!-- pagination_component.html.erb -->
<ul role="pagination">
  <% 1.upto(@max).each do |n| %>
    <li>
      <% if n == @current %>
        <%= current_page.with(number: n) %>
      <% else %>
        <%= page.with(number: n) %>
      <% end %>
    </li>
  <% end %>
</ul>
<!-- usage -->
<%= render PaginationComponent.new(current: 2, max: 3) do |c| %>
  <% c.current_page do |p| %><a href>#{p.number}</a><% end %>
  <% c.page do |p| %><span>#{p.number}</span><% end %>
<% end %>
<!-- rendered -->
<ul>
  <li><span>1</span></li>
  <li><a href>2</a></li>
  <li><span>3</span></li>
</ul>

Base automatically changed from master to main December 21, 2020 21:44
@BlakeWilliams
Copy link
Contributor

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 data attribute but also sets columns explicitly, when I think it could be reversed. e.g. We can tell the table the columns it should render, and we should be explicitly giving it rows.

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
end

This 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
end

It'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.

@BlakeWilliams
Copy link
Contributor

It's also possible to replace the content_tag bits with an actual component, which may be closer to your example?

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>

@rewritten
Copy link
Author

I think your first two examples are possible today.

You mean, defining public accessors on the component itself will achieve the desired result? Nice :)

We can tell the table the columns it should render, and we should be explicitly giving it rows.

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:

  • The table > thead > tr:first > th elements would have arrows and be links depending on
    • whether the column is marked as "sortable"
    • whether the sort param matches the asc or desc key for the column
    • (note that for this the column name must be param-like)
  • The labels of the column could be translated, or just different from the key
  • The table > thead > tr:last > th elements could be present if the column is made "searchable", and the content would be an input prepopulated with the corresponding filter value
  • The table > tbody > tr > td elements can contain arbitrary HTML around the value

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
end

would 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 safe_join and other ugly helpers, instead intermixing it with the template would be nice:

<%= 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 %>

@rewritten
Copy link
Author

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.

@BlakeWilliams
Copy link
Contributor

Ideally, I would not "build" the whole render mini-blocks for each column, because in complex cases one has to use safe_join and other ugly helpers, instead intermixing it with the template would be nice:

<%= 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 %>

I think this example would mostly work, except elm would be a component instance. If you exposed the elm on the component, I think that may work?

e.g.

  <% t.column :quz do |component| %>
    <b><%= component.quz %></b>
    <i><small><%= component.elm.quz_extra_text %></small></i>
  <% end %>

@rewritten
Copy link
Author

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.

@rewritten
Copy link
Author

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 SlotV2#with(**overridden_args).

@rewritten rewritten force-pushed the contextual-components branch 2 times, most recently from a123898 to 093e156 Compare January 13, 2021 09:48
@chrisortman
Copy link

@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

@Spone Spone added the slots label Jan 2, 2023
@joelhawksley
Copy link
Member

Closing as stale. Please reopen if you'd like to continue your work here ❤️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants