Skip to content
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

Implement Slots as potential successor to Content Areas #348

Merged
merged 16 commits into from
Jun 30, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# master

* Implement Slots as potential successor to Content Areas.

*Jens Ljungblad, Brian Bugh, Jon Palmer, Joel Hawksley*

# 2.11.1

* Fix kwarg warnings in Ruby 2.7.
Expand Down
135 changes: 131 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,133 @@ Returning:
</div>
```

#### Slots (experimental)

_Slots are currently under development as a successor to Content Areas. The Slot APIs should be considered unfinished and subject to breaking changes in non-major releases of ViewComponent._

Slots enable multiple blocks of content to be passed to a single ViewComponent.

Slots exist in two forms: normal slots and collection slots.

Normal slots can be rendered once per component. They expose an accessor with the name of the slot that returns an instance of `BoxComponent::Header`, etc.

Collection slots can be rendered multiple times. They expose an accessor with the pluralized name of the slot (`#rows`), which is an Array of `BoxComponent::Row` instances.
Comment on lines +171 to +173
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Named slot subclasses aren't automatically generated after cc4ddc8 right?

Suggested change
Normal slots can be rendered once per component. They expose an accessor with the name of the slot that returns an instance of `BoxComponent::Header`, etc.
Collection slots can be rendered multiple times. They expose an accessor with the pluralized name of the slot (`#rows`), which is an Array of `BoxComponent::Row` instances.
Normal slots can be rendered once per component. They expose an accessor with the name of the slot that returns an instance of `ViewComponent::Slot`, etc.
Collection slots can be rendered multiple times. They expose an accessor with the pluralized name of the slot (`#rows`), which is an Array of `ViewComponent::Slot` instances.


To learn more about the design of the Slots API, see https://github.com/github/view_component/pull/348.

##### Defining Slots

Slots are defined by the `with_slot` macro:

`with_slot :header`

To define a collection slot, add `collection: true`:

`with_slot :row, collection: true`

To define a slot with a custom class, pass `class_name`:

`with_slot :body, class_name: 'BodySlot`

Slot classes should be subclasses of `ViewComponent::Slot`.

##### Example ViewComponent with Slots

`# box_component.rb`
```ruby
class BoxComponent < ViewComponent::Base
include ViewComponent::Slotable

with_slot :body, :footer
with_slot :header, class_name: "Header"
with_slot :row, collection: true, class_name: "Row"

class Header < ViewComponent::Slot
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it would be possible to have slots still inherit from ViewComponent::Base?

If we could do that, I think it'd be one less thing to remember and extracting full components is easier since you no longer have to change what it inherits from.

Copy link
Member Author

@joelhawksley joelhawksley Jun 16, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could see that being possible, but my concern with that approach would be that it might imply that components could be used as Slots, when a Slot is just a simple accessor object.

I do wonder if there's a simpler way to express the Slot interface though, as all we really care about is that the object has a content accessor.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't help scratch the itch for mr that slots could themselves be ViewComponents. I've mentioned this before and I don't yet have the concrete uses cases (though i have my suspicions) so I'm prepared to let this go for a future release.

But here is my thinking. If you stripped out some of the magic around class_name and generating classes we're providing in with_slot you could imagine that you had to use slots like this:

<% component.slot(:header, HeaderSlot.new(class_names: "my-class-name")) do %>
    This is my header!
  <% end %>

the requirement on HeaderSlot being that it provide a content property to put the captured block in.

Under that light the component.slot is looking an awful lot like a render call on a ViewComponent.

Now imagine the guts of the slot method was something like this:

    slot = slots[slot_name]

    if args.is_a?(Hash)
      # The class name of the Slot, such as Header
      slot_class = self.class.const_get(slot[:class_name])

      # Instantiate Slot class, accommodating Slots that don't accept arguments
      slot_instance = args.present? ? slot_class.new(args) : slot_class.new
    else
      # args were really a class
      slot_instance = args
    end

    if slot_instance.responds_to(:render_in)
      # RenderInSlotWrapper exposes a `content` property that is the result of `render_in`
      # I'd need to think it through more but I think we need some sort of wrapper that 'captures' the entirety of the rendered ViewComponent
      slot_instance = RenderInSlotWrapper.new(slot_instance).render_in(view_context, &block) 
    else
      # Capture block and assign to slot_instance#content
      slot_instance.content = view_context.capture(&block) if block_given?
    end

My spidey senses says there is something power here.

Copy link
Contributor

@jensljungblad jensljungblad Jun 21, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jonspalmer I disagree with the following though:

<% component.slot(:header, HeaderSlot.new(class_names: "my-class-name")) do %>
  This is my header!
<% end %>

If the HeaderSlot is publicly accessible like that, I wouldn't view it as a slot anymore. Slots are internal to the component and shouldn't be usable outside of the component, imo. You can of course put a component in a slot, which would be a very common use case of component composition:

<%= render(MediaObjectComponent.new) do |media_object| %>
  <% media_object.slot(:left) do %>
    <%= render(AvatarComponent.new(image: comment.image)) %>
  <% end %>
  <% media_object.slot(:right) do %>
    <%= comment.body %>
  <% end %>
<% end %>

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with the pattern of using a component in a slot. I believe that will be powerful enough for many uses cases.

However, I don't really see the 'internal' piece of a component quite the same way. I see Slots as defining an interface between the component and the slots it uses. Its an Interface not a concrete implementation. As such a ViewComponent leveraging slots comes with so slot classes. IMO it would be powerful allow those classes to be extended. Its let about using those Slot classes outside the component its that I can extend them (subclassing most likely) to be used in the component.

For example consider the Row example from the docs

class Row < ViewComponent::Slot
    def initialize(theme: :gray)
      @theme = theme
    end

    def theme_class_name
      case @theme
      when :gray
        "Box-row--gray"
      when :hover_gray
        "Box-row--hover-gray"
      when :yellow
        "Box-row--yellow"
      when :blue
        "Box-row--blue"
      when :hover_blue
        "Box-row--hover-blue"
      else
        "Box-row--gray"
      end
    end
  end

The interface that the component relies on is that Row have a content getter/setter and it has a getter for theme_class_name.

No suppose I want to use this component but add some other classes to the header based on logic special to my use case. I'd do this:

class MySpecialRow < Row

  def initialize(theme: :gray, special: :foo)
    super(theme)
    @special = special
  end

  def theme_class_name
     special_classes = some_other_logic(special)
     "#{super} #{special_classes}
  end
end

and then in the component usage

  <% component.slot(:row, MySpecialRow.new(theme: :hover_blue, special: :make_some_magic) do %>
    Special row
  <% end %>

Copy link
Contributor

@jensljungblad jensljungblad Jun 27, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, couldn't you use composition instead? I'm just thinking composition is generally a better pattern in the view layer than overriding/extending. It's the only way you can build components in React, and it works pretty well there. But I admit I have a hard time seeing all the possibilities of 3rd party components and how people would want to extend them.

I guess to phrase it differently, let's say there's a 3rd party library/gem with Twitter bootstrap components built with view_component. Would you really want to modify them, or would you rather wrap them (composition) with your own components that hold the specialized behavior?

def initialize(class_names: "")
@class_names = class_names
end

def class_names
"Box-header #{@class_names}"
end
end

class Row < ViewComponent::Slot
def initialize(theme: :gray)
@theme = theme
end

def theme_class_name
case @theme
when :gray
"Box-row--gray"
when :hover_gray
"Box-row--hover-gray"
when :yellow
"Box-row--yellow"
when :blue
"Box-row--blue"
when :hover_blue
"Box-row--hover-blue"
else
"Box-row--gray"
end
end
end
end
```

`# box_component.html.erb`
```erb
<div class="Box">
<% if header %>
<div class="<%= header.class_names %>">
<%= header.content %>
</div>
<% end %>
<% if body %>
<div class="Box-body">
<%= body.content %>
</div>
<% end %>
<% if rows.any? %>
<ul>
<% rows.each do |row| %>
<li class="Box-row <%= row.theme_class_name %>">
<%= row.content %>
</li>
<% end %>
</ul>
<% end %>
<% if footer %>
<div class="Box-footer">
<%= footer %>
</div>
<% end %>
</div>
```

`# index.html.erb`
```erb
<%= render(BoxComponent.new) do |component| %>
<% component.slot(:header, class_names: "my-class-name") do %>
This is my header!
<% end %>
<% component.slot(:body) do %>
This is the body.
<% end %>
<% component.slot(:row) do %>
Row one
<% end %>
<% component.slot(:row, theme: :yellow) do %>
Yellow row
<% end %>
<% component.slot(:footer) do %>
This is the footer.
<% end %>
<% end %>
```

### Inline Component

ViewComponents can render without a template file, by defining a `call` method:
Expand Down Expand Up @@ -785,10 +912,10 @@ ViewComponent is built by:
|@simonrand|@fugufish|@cover|@franks921|@fsateler|
|Dublin, Ireland|Salt Lake City, Utah|Barcelona|South Africa|Chile|

|<img src="https://avatars.githubusercontent.com/maxbeizer?s=256" alt="maxbeizer" width="128" />|<img src="https://avatars.githubusercontent.com/franco?s=256" alt="franco" width="128" />|<img src="https://avatars.githubusercontent.com/tbroad-ramsey?s=256" alt="tbroad-ramsey" width="128" />|
|:---:|:---:|:---:|
|@maxbeizer|@franco|@tbroad-ramsey|
|Nashville, TN|Switzerland|Spring Hill, TN|
|<img src="https://avatars.githubusercontent.com/maxbeizer?s=256" alt="maxbeizer" width="128" />|<img src="https://avatars.githubusercontent.com/franco?s=256" alt="franco" width="128" />|<img src="https://avatars.githubusercontent.com/tbroad-ramsey?s=256" alt="tbroad-ramsey" width="128" />|<img src="https://avatars.githubusercontent.com/jensljungblad?s=256" alt="jensljungblad" width="128" />|<img src="https://avatars.githubusercontent.com/bbugh?s=256" alt="bbugh" width="128" />|
|:---:|:---:|:---:|:---:|:---:|
|@maxbeizer|@franco|@tbroad-ramsey|@jensljungblad|@bbugh|
|Nashville, TN|Switzerland|Spring Hill, TN|New York, NY|Austin, TX|

## License

Expand Down
4 changes: 2 additions & 2 deletions coverage/coverage.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ Incomplete test coverage
--------------------------------------------------------------------------------

/app/controllers/view_components_controller.rb: 97.22% (missed: 49)
/lib/view_component/base.rb: 97.8% (missed: 170,253,281,344)
/lib/view_component/base.rb: 97.85% (missed: 175,262,290,353)
/lib/view_component/preview.rb: 92.31% (missed: 32,42,78)
/lib/view_component/render_to_string_monkey_patch.rb: 83.33% (missed: 9)
/lib/view_component/test_helpers.rb: 95.45% (missed: 17)
/test/view_component/integration_test.rb: 97.54% (missed: 14,15,16,18,20)
/test/view_component/integration_test.rb: 97.66% (missed: 14,15,16,18,20)
9 changes: 9 additions & 0 deletions lib/view_component/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
require "view_component/collection"
require "view_component/compile_cache"
require "view_component/previewable"
require "view_component/slotable"

module ViewComponent
class Base < ActionView::Base
Expand All @@ -17,6 +18,10 @@ class Base < ActionView::Base
class_attribute :content_areas
self.content_areas = [] # class_attribute:default doesn't work until Rails 5.2

# Hash of registered Slots
class_attribute :slots
self.slots = {}
Comment on lines +21 to +23
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like this might belong in the new Slotable module instead of base?


# Entrypoint for rendering components.
#
# view_context: ActionView context from calling view
Expand Down Expand Up @@ -181,6 +186,10 @@ def inherited(child)
# has been re-defined by the consuming application, likely in ApplicationComponent.
child.source_location = caller_locations(1, 10).reject { |l| l.label == "inherited" }[0].absolute_path

# Clone slot configuration into child class
# see #test_slots_pollution
child.slots = self.slots.clone
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like it might also belong in the Slotable module?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In theory, I agree. I avoided doing so as I didn't want to re-define included in the module.


super
end

Expand Down
7 changes: 7 additions & 0 deletions lib/view_component/slot.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

module ViewComponent
class Slot
attr_accessor :content
end
end
121 changes: 121 additions & 0 deletions lib/view_component/slotable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# frozen_string_literal: true

require "active_support/concern"

require "view_component/slot"

module ViewComponent
module Slotable
extend ActiveSupport::Concern

class_methods do
# support initializing slots as:
#
# with_slot(
# :header,
# collection: true|false,
# class_name: "Header" # class name string, used to instantiate Slot
# )
def with_slot(*slot_names, collection: false, class_name: nil)
slot_names.each do |slot_name|
# Ensure slot_name is not already declared
if self.slots.key?(slot_name)
raise ArgumentError.new("#{slot_name} slot declared multiple times")
end

# Ensure slot name is not :content
if slot_name == :content
raise ArgumentError.new ":content is a reserved slot name. Please use another name, such as ':body'"
end

# Set the name of the method used to access the Slot(s)
accessor_name =
if collection
# If Slot is a collection, set the accessor
# name to the pluralized form of the slot name
# For example: :tab => :tabs
ActiveSupport::Inflector.pluralize(slot_name)
else
slot_name
end

instance_variable_name = "@#{accessor_name}"

# If the slot is a collection, define an accesor that defaults to an empty array
if collection
class_eval <<-RUBY
def #{accessor_name}
#{instance_variable_name} ||= []
end
RUBY
else
attr_reader accessor_name
end

# Default class_name to ViewComponent::Slot
class_name = "ViewComponent::Slot" unless class_name.present?

# Register the slot on the component
self.slots[slot_name] = {
class_name: class_name,
instance_variable_name: instance_variable_name,
collection: collection
}
end
end
end

# Build a Slot instance on a component,
# exposing it for use inside the
# component template.
#
# slot: Name of Slot, in symbol form
# **args: Arguments to be passed to Slot initializer
#
# For example:
# <%= render(SlotsComponent.new) do |component| %>
# <% component.slot(:footer, class_names: "footer-class") do %>
# <p>This is my footer!</p>
# <% end %>
# <% end %>
#
def slot(slot_name, **args, &block)
# Raise ArgumentError if `slot` does not exist
unless slots.keys.include?(slot_name)
raise ArgumentError.new "Unknown slot '#{slot_name}' - expected one of '#{slots.keys}'"
end

slot = slots[slot_name]

# The class name of the Slot, such as Header
slot_class = self.class.const_get(slot[:class_name])

unless slot_class <= ViewComponent::Slot
raise ArgumentError.new "#{slot[:class_name]} must inherit from ViewComponent::Slot"
end

# Instantiate Slot class, accommodating Slots that don't accept arguments
slot_instance = args.present? ? slot_class.new(args) : slot_class.new

# Capture block and assign to slot_instance#content
slot_instance.content = view_context.capture(&block) if block_given?

if slot[:collection]
# Initialize instance variable as an empty array
# if slot is a collection and has yet to be initialized
unless instance_variable_defined?(slot[:instance_variable_name])
instance_variable_set(slot[:instance_variable_name], [])
end

# Append Slot instance to collection accessor Array
instance_variable_get(slot[:instance_variable_name]) << slot_instance
else
# Assign the Slot instance to the slot accessor
instance_variable_set(slot[:instance_variable_name], slot_instance)
end

# Return nil, as this method should not output anything to the view itself.
nil
end
end
end
1 change: 1 addition & 0 deletions test/app/components/bad_slot_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<%= title %>
10 changes: 10 additions & 0 deletions test/app/components/bad_slot_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true

class BadSlotComponent < ViewComponent::Base
include ViewComponent::Slotable

with_slot :title, class_name: "Title"

# slots must inherit from ViewComponent::Slot!
class Title; end
end
33 changes: 33 additions & 0 deletions test/app/components/slots_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<div class="card <%= @class_names %>">
<div class="title">
<%= title.content %>
</div>
<div class="subtitle">
<%= subtitle.content %>
</div>
<div class="nav">
<% if tabs.any? %>
<% tabs.each do |tab| %>
<div class="tab">
<%= tab.content %>
</div>
<% end %>
<% else %>
No tabs provided
<% end %>
</div>
<div class="body">
<% if items.any? %>
<% items.each do |item| %>
<div class="item <%= item.class_names %>">
<%= item.content %>
</div>
<% end %>
<% else %>
No items provided
<% end %>
</div>
<div class="footer <%= footer.class_names %>">
<%= footer.content %>
</div>
</div>
Loading