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

Multiple templates per component, allowing arguments #451

Closed
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 docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,10 @@ Run into an issue with this release? [Let us know](https://github.com/ViewCompon

*Anton Prins*

* Add experimental support for sub-templates.

*Felipe Sateler*, *Rob Sterner*, *Joel Hawksley*

## 2.72.0

* Deprecate support for Ruby < 2.7 for removal in v3.0.0.
Expand Down
69 changes: 69 additions & 0 deletions docs/adrs/0005-allow-sub-templates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 1. Sub-templates

## Author

Felipe Sateler

## Status

Accepted

## Context

As views become larger (such as an entire page), it becomes useful to be able to extract sections of the view to a different file. In ActionView this is done with partials, but ViewComponent lacks a similar mechanism.

The interface of ActionView partials can't be introspected. Because data may be passed into the partial via ivars or locals it is impossible to know which without reading the file. Partials are also globally accessible, making it difficult to determine if a given partial is in use or not.

Check failure on line 15 in docs/adrs/0005-allow-sub-templates.md

View workflow job for this annotation

GitHub Actions / prose

[vale] reported by reviewdog 🐶 [Microsoft.Contractions] Use 'it's' instead of 'it is'. Raw Output: {"message": "[Microsoft.Contractions] Use 'it's' instead of 'it is'.", "location": {"path": "docs/adrs/0005-allow-sub-templates.md", "range": {"start": {"line": 15, "column": 125}}}, "severity": "ERROR"}

## Considered Options

* Introduce sub-templates to components
* Do nothing

### Sub-templates

Introduce support for multiple ERB templates within a single component and make it possible to invoke them from the main view, explicitly listing the arguments the additional templates accept. This allows a single method to be compiled and invoked directly.

**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 aren't 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.

### Do nothing

**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 coupled but split components.
* Creates new public components thus expanding component library API surface.

## Decision

Support multiple sidecar templates. Compile each template into its own method `render_<template_name>_template`. To allow the compiled method to receive arguments,
the template must define them using the same syntax as [Rails' Strict Locals](https://edgeguides.rubyonrails.org/action_view_overview.html#strict-locals), with one difference: a missing strict locals tag means the template takes no arguments (equivalent to `locals: ()`).

## 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.

There is no provision at the moment to allow `render(*)` to render a sub template. This could be
added later if necessary and it becomes desirable.

The generated methods are only invokable via keyword arguments, inheriting the limitation
from ActionView.

The generated methods are public, and thus could be invoked by a third party. There is
no pressing need to make the methods private, and we avoid introducing new concepts
into ViewComponent.
69 changes: 69 additions & 0 deletions docs/guide/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,72 @@ class MyComponent < ViewComponent::Base
strip_trailing_whitespace(false)
end
```

## Sub-templates

Since 2.73.0
{: .label }

Experimental
{: .label .label-yellow }

ViewComponents can render sub-templates defined in the sidecar directory:

```text
app/components
├── ...
├── test_component.rb
├── test_component
| ├── list.html.erb
| └── summary.html.erb
├── ...
```

Templates are compiled to private methods in the format `render_#{template_basename}_template`:

```ruby
class TestComponent < ViewComponent::Base
def initialize(mode:)
@mode = mode
end

def call
case @mode
when :list
render_list_template
when :summary
render_summary_template
end
end
end
```

To define which parameters a sub-template accepts, use the [Rails Strict Locals](https://edgeguides.rubyonrails.org/action_view_overview.html#strict-locals) syntax.

_Note: Unlike Rails, a missing `locals` declaration means the template takes no arguments._

```erb
<%# list.html.erb %>
<%# locals: (multiple:) %->
The parameter is <%= multiple %>
```

```ruby
# test_component.rb
class TestComponent < ViewComponent::Base
def initialize(mode:)
@mode = mode
end

def call
case @mode
when :list
render_list_template multiple: false
when :multilist
render_list_template multiple: true
when :summary
render_summary_template
end
end
end
```
2 changes: 1 addition & 1 deletion lib/view_component/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,7 @@ def sidecar_files(extensions)
# view files in the same directory as the component
sidecar_files = Dir["#{directory}/#{component_name}.*{#{extensions}}"]

sidecar_directory_files = Dir["#{directory}/#{component_name}/#{filename}.*{#{extensions}}"]
sidecar_directory_files = Dir["#{directory}/#{component_name}/*.*{#{extensions}}"]

(sidecar_files - [source_location] + sidecar_directory_files + nested_component_files).uniq
end
Expand Down
66 changes: 43 additions & 23 deletions lib/view_component/compiler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class Compiler
DEVELOPMENT_MODE = :development
PRODUCTION_MODE = :production

EXPLICIT_LOCALS_REGEX = /\#\s+locals:\s+\((.*)\)/

class_attribute :mode, default: PRODUCTION_MODE

def initialize(component_class)
Expand Down Expand Up @@ -68,15 +70,19 @@ def render_template_for(variant = nil)
end
else
templates.each do |template|
method_name = call_method_name(template[:variant])
method_name, method_args = method_name_and_args_for template
visibility = method_name.start_with?("call") ? "" : "private "
@variants_rendering_templates << template[:variant]

redefinition_lock.synchronize do
component_class.silence_redefinition_of_method(method_name)
# rubocop:disable Style/EvalWithLocation
component_class.class_eval <<-RUBY, template[:path], 0
def #{method_name}
component_class.class_eval <<-RUBY, template[:path], -1
#{visibility}def #{method_name}(#{method_args})
old_buffer = @output_buffer if defined? @output_buffer
#{compiled_template(template[:path])}
ensure
@output_buffer = old_buffer
end
RUBY
# rubocop:enable Style/EvalWithLocation
Expand All @@ -102,7 +108,7 @@ def renders_template_for_variant?(variant)
def define_render_template_for
variant_elsifs = variants.compact.uniq.map do |variant|
safe_name = "_call_variant_#{normalized_variant_name(variant)}_#{safe_class_name}"
component_class.define_method(safe_name, component_class.instance_method(call_method_name(variant)))
component_class.define_method(safe_name, component_class.instance_method(call_method_name(nil, variant)))

"elsif variant.to_sym == :'#{variant}'\n #{safe_name}"
end.join("\n")
Expand Down Expand Up @@ -141,27 +147,28 @@ def template_errors
errors << "Couldn't find a template file or inline render method for #{component_class}."
end

if templates.count { |template| template[:variant].nil? } > 1
errors <<
"More than one template found for #{component_class}. " \
"There can only be one default template file per component."
end

invalid_variants =
invalid_templates =
templates
.group_by { |template| template[:variant] }
.map { |variant, grouped| variant if grouped.length > 1 }
.group_by { |template| template[:variant].present? ? "#{template[:base_name]}+#{template[:variant]}" : template[:base_name] }
.map { |template, grouped| template if grouped.length > 1 }
.compact
.sort

unless invalid_variants.empty?
unless invalid_templates.empty?
errors <<
"More than one template found for #{"variant".pluralize(invalid_variants.count)} " \
"#{invalid_variants.map { |v| "'#{v}'" }.to_sentence} in #{component_class}. " \
"More than one template+variant found for #{"template".pluralize(invalid_templates.count)} " \
"#{invalid_templates.map { |v| "'#{v}'" }.to_sentence} in #{component_class}. " \
"There can only be one template file per variant."
end

if templates.find { |template| template[:variant].nil? } && inline_calls_defined_on_self.include?(:call)
default_template_exists =
templates.find do |template|
pieces = File.basename(template[:path]).split(".")

template[:variant].nil? && pieces.first == component_class.name.demodulize.underscore
end

if default_template_exists && inline_calls_defined_on_self.include?(:call)
errors <<
"Template file and inline render method found for #{component_class}. " \
"There can only be a template file or inline render method per component."
Expand Down Expand Up @@ -207,6 +214,7 @@ def templates
pieces = File.basename(path).split(".")
memo << {
path: path,
base_name: pieces.first,
variant: pieces[1..-2].join(".").split("+").second&.to_sym,
handler: pieces.last
}
Expand All @@ -229,6 +237,14 @@ def inline_calls
end
end

def method_name_and_args_for(template)
file = File.read(template[:path])
file.match(EXPLICIT_LOCALS_REGEX)
explicit_locals = Regexp.last_match(1)
basename = template[:base_name]
[call_method_name(basename, template[:variant]), explicit_locals]
end

def inline_calls_defined_on_self
@inline_calls_defined_on_self ||= component_class.instance_methods(false).grep(/^call(_|$)/)
end
Expand Down Expand Up @@ -277,12 +293,16 @@ def compile_template(template, handler)
# :nocov:
end

def call_method_name(variant)
if variant.present? && variants.include?(variant)
"call_#{normalized_variant_name(variant)}"
else
"call"
end
def call_method_name(template, variant)
name =
if template.blank? || template == component_class.name.demodulize.underscore
"call"
else
"render"
end
name += "_#{normalized_variant_name(variant)}" if variant.present? && variants.include?(variant)
name += "_#{template}_template" if template.present? && template != component_class.name.demodulize.underscore
name.freeze
end

def normalized_variant_name(variant)
Expand Down
13 changes: 13 additions & 0 deletions test/sandbox/app/components/sub_templates_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

class SubTemplatesComponent < ViewComponent::Base
attr_reader :number
attr_reader :string

def initialize number: 1, string: "foo"
super()
@items = ["Apple", "Banana", "Pear"]
@number = number
@string = string
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<%# locals: (number:) %>
<ol data-number="<%= number %>">
<% @items.each do |item| %>
<li><%= item %></li>
<% end %>
</ol>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<%# locals: (number:) %>
<ul data-number="<%= number %>">
<% @items.each do |item| %>
<li><%= item %></li>
<% end %>
</ul>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<div class="container">
<%= render_summary_template string: string %>
<%= render_ordered_list_template number: number %>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<div class="container">
<%= render_summary_template string: string %>
<%= render_list_template number: number %>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<%# locals: (string:) %>
<div>The items are: <%= @items.to_sentence %>, <%= string %></div>
24 changes: 24 additions & 0 deletions test/sandbox/test/compiler_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

require "test_helper"

module ViewComponent
class CompilerTest < TestCase
def test_generates_sub_template_methods
primary_template = SubTemplatesComponent.public_instance_method(:call)
assert_empty primary_template.parameters

list_template = SubTemplatesComponent.instance_method(:render_list_template)
assert_includes SubTemplatesComponent.private_instance_methods, :render_list_template, "Render method is not private"
assert_equal [[:keyreq, :number]], list_template.parameters

list_template = SubTemplatesComponent.instance_method(:render_ordered_list_template)
assert_includes SubTemplatesComponent.private_instance_methods, :render_ordered_list_template, "Render method is not private"
assert_equal [[:keyreq, :number]], list_template.parameters

summary_template = SubTemplatesComponent.instance_method(:render_summary_template)
assert_includes SubTemplatesComponent.private_instance_methods, :render_summary_template, "Render method is not private"
assert_equal [[:keyreq, :string]], summary_template.parameters
end
end
end
Loading
Loading