Skip to content

Commit

Permalink
Add ability to define templates that take parameters
Browse files Browse the repository at this point in the history
  • Loading branch information
jsolas committed Oct 16, 2021
1 parent 4e864d5 commit dff7c0d
Show file tree
Hide file tree
Showing 9 changed files with 116 additions and 47 deletions.
88 changes: 88 additions & 0 deletions adr/0003-multiple-templates-per-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# 1. Allow multiple templates

Date: 2021-10-13

## Status

Proposed.

## Context

As components become larger (for example, because you are implementing a whole page), it becomes
useful to be able to extract sections of the view to a different file. ActionView has
partials, and ViewComponent lacks a similar mechanism.

ActionView partials have the problem that their interface is not introspectable. Data
may be passed into the partial via ivars or locals, and it is impossible to know
which without actually opening up the file. Additionally, partials are globally
invocable, thus making it difficult to detect if a given partial is in use or not,
and who are its users.

## Considered Options

* Introduce component partials to components
* Keep components as-is

### Component partials

Allow multiple ERB templates available within the component, and make it possible to
invoke them from the main view.Templates are compiled to methods in the format `render_#{template_basename}(locals = {})`

**Pros:**
* Better performance due to lack of GC pressure and object creation
* Reduces the number of components needed to express a more complex view.
* Extracted sections are not exposed outside the component, thus reducing component library API surface.

**Cons:**
* Another concept for users of ViewComponent to learn and understand.
* Components are no longer the only way to encapsulate behavior.

### Partial components by [fstaler](https://github.com/fsateler)

In this approach the template arguments are previously defined in the `template_arguments` method

[See here](https://github.com/github/view_component/pull/451):

**Pros:**
* Better performance due to lack of GC pressure and object creation
* Reduces the number of components needed to express a more complex view.
* Extracted sections are not exposed outside the component, thus reducing component library API surface.

**Cons:**
* Another concept for users of ViewComponent to learn and understand.
* Components are no longer the only way to encapsulate behavior.
* `call_foo` api feels awkward and not very Rails like
* Declare templates and their arguments explicitly before using them

### Keeping components as-is

**Pros:**
* The API remains simple and components are the only way to encapsulate behavior.
* Encourages creating reusable sub-components.

**Cons:**
* Extracting a component results in more GC and intermediate objects.
* Extracting a component may result in tightly coupled but split components.
* Creates new public components thus expanding component library API surface.

## Decision

We will allow having multiple templates in the sidecar asset. Each asset will be compiled to
it's own method `render_<template_name>`. I think it is simple, similar to rails and meets what is expected

## Consequences

This implementation has better performance characteristics over both an extracted component
and ActionView partials, because it avoids creating intermediate objects, and the overhead of
creating bindings and `instance_exec`.
Having explicit arguments makes the interface explicit.

TODO: The following are consequences of the current approach, but the approach might be extended
to avoid them:

The interface to render a sidecar partial would be a method call, and depart from the usual
`render(*)` interface used in ActionView.

The generated methods cannot have arguments with default values.

The generated methods are public, and thus could be invoked by a third party.
8 changes: 4 additions & 4 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ title: Changelog

*Hans Lemuet*

* Add support for multiple templates.

*Rob Sterner*, *Joel Hawksley*

## 2.40.0

* Replace antipatterns section in the documentation with best practices.
Expand Down Expand Up @@ -113,10 +117,6 @@ title: Changelog

*Joel Hawksley*

* Add support for multiple templates.

*Rob Sterner*, *Joel Hawksley*

## 2.36.0

* Add `slot_type` helper method.
Expand Down
14 changes: 7 additions & 7 deletions docs/guide/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,11 @@ class MyLinkComponent < LinkComponent
end
```

#### Multiple templates
## Multiple templates with arguments

ViewComponents can render multiple templates defined in the sidecar directory:
ViewComponents can render multiple templates defined in the sidecar directory and send arguments to it:

```
```console
app/components
├── ...
├── test_component.rb
Expand All @@ -107,7 +107,7 @@ app/components
├── ...
```

Templates are compiled to methods in the format `call_#{template_basename}`, which can then be called in the component.
Templates are compiled to methods in the format `render_#{template_basename}(locals = {})`, which can then be called in the component.

```ruby
class TestComponent < ViewComponent::Base
Expand All @@ -118,10 +118,10 @@ class TestComponent < ViewComponent::Base
def call
case @mode
when :list
call_list
render_list number: 1
when :summary
call_summary
render_summary string: "foo"
end
end
end
```
```
12 changes: 7 additions & 5 deletions lib/view_component/compiler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,20 +50,22 @@ def compile(raise_errors: false)
# set the method name with call_method_name
if pieces.first == component_class.name.demodulize.underscore
call_method_name(template[:variant])
# Otherwise, append the name of the template to
# call_method_name
# Otherwise, append the name of the template to render_
else
"#{call_method_name(template[:variant])}_#{pieces.first.to_sym}"
"render_#{pieces.first.to_sym}"
end

if component_class.instance_methods.include?(method_name.to_sym)
component_class.send(:undef_method, method_name.to_sym)
end

component_class.class_eval <<-RUBY, template[:path], -1
def #{method_name}
component_class.class_eval <<-RUBY, template[:path], -2
def #{method_name}(**locals)
old_buffer = @output_buffer if defined? @output_buffer
@output_buffer = ActionView::OutputBuffer.new
#{compiled_template(template[:path])}
ensure
@output_buffer = old_buffer
end
RUBY
end
Expand Down
13 changes: 1 addition & 12 deletions test/sandbox/app/components/multiple_templates_component.rb
Original file line number Diff line number Diff line change
@@ -1,18 +1,7 @@
# frozen_string_literal: true

class MultipleTemplatesComponent < ViewComponent::Base
def initialize(mode:)
@mode = mode

def initialize
@items = ["Apple", "Banana", "Pear"]
end

def call
case @mode
when :list
call_list
when :summary
call_summary
end
end
end
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<ul>
<ul data-number="<%= locals[:number] %>">
<% @items.each do |item| %>
<li><%= item %></li>
<% end %>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<div class="container">
<%= render_summary string: "foo" %>
<%= render_list number: 1 %>
</div>
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<div>The items are: <%= @items.to_sentence %></div>
<div>The items are: <%= @items.to_sentence %>, <%= locals[:string] %></div>
20 changes: 3 additions & 17 deletions test/view_component/view_component_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -777,15 +777,14 @@ def test_collection_component_with_trailing_comma_attr_reader
end

def test_render_multiple_templates
render_inline(MultipleTemplatesComponent.new(mode: :list))
render_inline(MultipleTemplatesComponent.new)

assert_selector("div", text: "The items are: Apple, Banana, and Pear, foo")
assert_selector("li", text: "Apple")
assert_selector("li", text: "Banana")
assert_selector("li", text: "Pear")

render_inline(MultipleTemplatesComponent.new(mode: :summary))

assert_selector("div", text: "Apple, Banana, and Pear")
assert_selector("div.container")
end

def test_renders_component_using_rails_config
Expand Down Expand Up @@ -900,17 +899,4 @@ def test_output_postamble

assert_text("Hello, World!")
end

private

def modify_file(file, content)
filename = Rails.root.join(file)
old_content = File.read(filename)
begin
File.open(filename, "wb+") { |f| f.write(content) }
yield
ensure
File.open(filename, "wb+") { |f| f.write(old_content) }
end
end
end

0 comments on commit dff7c0d

Please sign in to comment.