Skip to content

Commit

Permalink
Enable (experimental) single file components (ViewComponent#1556)
Browse files Browse the repository at this point in the history
* Enable (experimental) single file components

This change adds an experimental module,
`ViewComponent::InlineTemplate` that allows you to define your
templates using a basic DSL directly within your component. This
eliminates the need for sidecar templates.

This is what it looks like:

```erb
class MyComponent < ApplicationComponent
  include ViewComponent::InlineTemplate

  template <<~ERB
    <div>
      <h1>Hello, <%= @name %>!</h1>
    </div>
  ERB

  def initialize(name:)
    @name = name
  end
end
```

This results in component being "self-contained", in that a single
file can represent the entire component. This makes it easier to
understand a component and requires less jumping around between files.
This also has benefits like enabling the following, which feels
closely related to our multi-template efforts:

(this is not implemented, just an example of what this functionality can enable)

```erb
class MyComponent < ApplicationComponent
  include ViewComponent::InlineTemplate

  template <<~ERB
    <div>
      <h1>Hello, <%= @name %>!</h1>

      <% post.each do.%>
        <%= render_row(post) %>
      <% end %>
    </div>
  ERB

  fragment :row, -> (post) {
    <<~ERB
    <tr>
      <td><%= post.title %></td>
      <td><%= post.body %></td>
    </tr>
    ERB
  }

  def initialize(name:)
    @name = name
  end

  def posts
    Post.all
  end
end
```

* Use consistent method names

* Add test using slots

* add docs

* use has_inline_template? method

---------

Co-authored-by: Joel Hawksley <[email protected]>
  • Loading branch information
2 people authored and claudiob committed Dec 22, 2023
1 parent e5c0983 commit f202733
Show file tree
Hide file tree
Showing 6 changed files with 267 additions and 12 deletions.
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ nav_order: 5

## main

* Add support for experimental inline templates.

*Blake Williams*

* Expose `translate` and `t` I18n methods on component classes.

*Elia Schito*
Expand Down
25 changes: 24 additions & 1 deletion docs/guide/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ bin/rails generate component Example title --sidecar
create app/components/example_component/example_component.html.erb
```

## Inline
## `#call`

Since 1.16.0
{: .label }
Expand Down Expand Up @@ -82,6 +82,29 @@ end

_**Note**: `call_*` methods must be public._

## Inline

Since 3.0.0
{: .label }

To define a template inside a component, include the experimental `ViewComponent::InlineTemplate` module and call the `.TEMPLATE_HANDLER_template` macro:

```ruby
class InlineErbComponent < ViewComponent::Base
include ViewComponent::InlineTemplate

attr_reader :name

erb_template <<~ERB
<h1>Hello, <%= name %>!</h1>
ERB

def initialize(name)
@name = name
end
end
```

## Inherited

Since 2.19.0
Expand Down
1 change: 1 addition & 0 deletions lib/view_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ module ViewComponent
autoload :ComponentError
autoload :Config
autoload :Deprecation
autoload :InlineTemplate
autoload :Instrumentation
autoload :Preview
autoload :PreviewTemplateError
Expand Down
60 changes: 49 additions & 11 deletions lib/view_component/compiler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,24 +51,46 @@ def compile(raise_errors: false, force: false)
component_class.validate_collection_parameter!
end

templates.each do |template|
# Remove existing compiled template methods,
# as Ruby warns when redefining a method.
method_name = call_method_name(template[:variant])
if has_inline_template?
template = component_class.inline_template

redefinition_lock.synchronize do
component_class.silence_redefinition_of_method(method_name)
component_class.silence_redefinition_of_method("call")
# rubocop:disable Style/EvalWithLocation
component_class.class_eval <<-RUBY, template[:path], 0
def #{method_name}
#{compiled_template(template[:path])}
component_class.class_eval <<-RUBY, template.path, template.lineno
def call
#{compiled_inline_template(template)}
end
RUBY
# rubocop:enable Style/EvalWithLocation

component_class.silence_redefinition_of_method("render_template_for")
component_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
def render_template_for(variant = nil)
call
end
RUBY
end
else
templates.each do |template|
# Remove existing compiled template methods,
# as Ruby warns when redefining a method.
method_name = call_method_name(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}
#{compiled_template(template[:path])}
end
RUBY
# rubocop:enable Style/EvalWithLocation
end
end
end

define_render_template_for
define_render_template_for
end

component_class.build_i18n_backend

Expand Down Expand Up @@ -103,12 +125,16 @@ def render_template_for(variant = nil)
end
end

def has_inline_template?
component_class.respond_to?(:inline_template) && component_class.inline_template.present?
end

def template_errors
@__vc_template_errors ||=
begin
errors = []

if (templates + inline_calls).empty?
if (templates + inline_calls).empty? && !has_inline_template?
errors << "Couldn't find a template file or inline render method for #{component_class}."
end

Expand Down Expand Up @@ -216,9 +242,21 @@ def variants_from_inline_calls(calls)
end
end

def compiled_inline_template(template)
handler = ActionView::Template.handler_for_extension(template.language)
template.rstrip! if component_class.strip_trailing_whitespace?

compile_template(template.source, handler)
end

def compiled_template(file_path)
handler = ActionView::Template.handler_for_extension(File.extname(file_path).delete("."))
template = File.read(file_path)

compile_template(template, handler)
end

def compile_template(template, handler)
template.rstrip! if component_class.strip_trailing_whitespace?

if handler.method(:call).parameters.length > 1
Expand Down
55 changes: 55 additions & 0 deletions lib/view_component/inline_template.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# frozen_string_literal: true

module ViewComponent # :nodoc:
module InlineTemplate
extend ActiveSupport::Concern
Template = Struct.new(:source, :language, :path, :lineno)

class_methods do
def method_missing(method, *args)
return super if !method.end_with?("_template")

if defined?(@__vc_inline_template_defined) && @__vc_inline_template_defined
raise ViewComponent::ComponentError, "inline templates can only be defined once per-component"
end

if args.size != 1
raise ArgumentError, "wrong number of arguments (given #{args.size}, expected 1)"
end

ext = method.to_s.gsub("_template", "")
template = args.first

@__vc_inline_template_language = ext

caller = caller_locations(1..1)[0]
@__vc_inline_template = Template.new(
template,
ext,
caller.absolute_path || caller.path,
caller.lineno
)

@__vc_inline_template_defined = true
end
ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true)

def respond_to_missing?(method, include_all = false)
method.end_with?("_template") || super
end

def inline_template
@__vc_inline_template
end

def inline_template_language
@__vc_inline_template_language if defined?(@__vc_inline_template_language)
end

def inherited(subclass)
super
subclass.instance_variable_set(:@__vc_inline_template_language, inline_template_language)
end
end
end
end
134 changes: 134 additions & 0 deletions test/sandbox/test/inline_template_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# frozen_string_literal: true

require "test_helper"

class InlineErbTest < ViewComponent::TestCase
class InlineErbComponent < ViewComponent::Base
include ViewComponent::InlineTemplate

attr_reader :name

erb_template <<~ERB
<h1>Hello, <%= name %>!</h1>
ERB

def initialize(name)
@name = name
end
end

class InlineRaiseErbComponent < ViewComponent::Base
include ViewComponent::InlineTemplate

attr_reader :name

erb_template <<~ERB
<h1>Hello, <%= raise ArgumentError, "oh no" %>!</h1>
ERB

def initialize(name)
@name = name
end
end

class InlineErbSubclassComponent < InlineErbComponent
erb_template <<~ERB
<h1>Hey, <%= name %>!</h1>
ERB
end

class InlineSlimComponent < ViewComponent::Base
include ViewComponent::InlineTemplate

attr_reader :name

slim_template <<~SLIM
h1
| Hello,
= " " + name
| !
SLIM

def initialize(name)
@name = name
end
end

class InheritedInlineSlimComponent < InlineSlimComponent
end

class SlotsInlineComponent < ViewComponent::Base
include ViewComponent::InlineTemplate

renders_one :greeting, InlineErbComponent

erb_template <<~ERB
<div class="greeting-container">
<%= greeting %>
</div>
ERB
end

test "renders inline templates" do
render_inline(InlineErbComponent.new("Fox Mulder"))

assert_selector("h1", text: "Hello, Fox Mulder!")
end

test "error backtrace locations work" do
error = assert_raises ArgumentError do
render_inline(InlineRaiseErbComponent.new("Fox Mulder"))
end

assert_match %r{test/sandbox/test/inline_template_test.rb:26}, error.backtrace[0]
end

test "renders inline slim templates" do
render_inline(InlineSlimComponent.new("Fox Mulder"))

assert_selector("h1", text: "Hello, Fox Mulder!")
end

test "inherits template_language" do
assert_equal "slim", InheritedInlineSlimComponent.inline_template_language
end

test "subclassed erb works" do
render_inline(InlineErbSubclassComponent.new("Fox Mulder"))

assert_selector("h1", text: "Hey, Fox Mulder!")
end

test "calling template methods multiple times raises an exception" do
error = assert_raises ViewComponent::ComponentError do
Class.new(InlineErbComponent) do
erb_template "foo"
erb_template "bar"
end
end

assert_equal "inline templates can only be defined once per-component", error.message
end

test "calling template methods with more or less than 1 argument raises" do
assert_raises ArgumentError do
Class.new(InlineErbComponent) do
erb_template
end
end

assert_raises ArgumentError do
Class.new(InlineErbComponent) do
erb_template "omg", "wow"
end
end
end

test "works with slots" do
render_inline SlotsInlineComponent.new do |c|
c.with_greeting("Fox Mulder")
end

assert_selector(".greeting-container h1", text: "Hello, Fox Mulder!")
end
end

0 comments on commit f202733

Please sign in to comment.