Skip to content

Commit

Permalink
Enable (experimental) single file components
Browse files Browse the repository at this point in the history
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
```
  • Loading branch information
BlakeWilliams committed Jan 27, 2023
1 parent 9c5a9c7 commit dfef286
Show file tree
Hide file tree
Showing 5 changed files with 223 additions and 11 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*

* Added example of a custom preview controller.

*Graham Rogers*
Expand Down
1 change: 1 addition & 0 deletions lib/view_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,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 component_class.respond_to?(:inline_template) && component_class.inline_template.present?
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?(symbol, include_all = false)
symbol.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
114 changes: 114 additions & 0 deletions test/sandbox/test/inline_template_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# 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

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
end

0 comments on commit dfef286

Please sign in to comment.