From dfef28660d4c379b5cc41819f4f565c8f30d7de0 Mon Sep 17 00:00:00 2001 From: Blake Williams Date: Thu, 20 Oct 2022 10:08:25 -0400 Subject: [PATCH] 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

Hello, <%= @name %>!

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

Hello, <%= @name %>!

<% post.each do.%> <%= render_row(post) %> <% end %>
ERB fragment :row, -> (post) { <<~ERB <%= post.title %> <%= post.body %> ERB } def initialize(name:) @name = name end def posts Post.all end end ``` --- docs/CHANGELOG.md | 4 + lib/view_component.rb | 1 + lib/view_component/compiler.rb | 60 +++++++++--- lib/view_component/inline_template.rb | 55 +++++++++++ test/sandbox/test/inline_template_test.rb | 114 ++++++++++++++++++++++ 5 files changed, 223 insertions(+), 11 deletions(-) create mode 100644 lib/view_component/inline_template.rb create mode 100644 test/sandbox/test/inline_template_test.rb diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 99cbed44b..8cea3600a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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* diff --git a/lib/view_component.rb b/lib/view_component.rb index 3570ad13e..5bd8b4a0e 100644 --- a/lib/view_component.rb +++ b/lib/view_component.rb @@ -12,6 +12,7 @@ module ViewComponent autoload :ComponentError autoload :Config autoload :Deprecation + autoload :InlineTemplate autoload :Instrumentation autoload :Preview autoload :PreviewTemplateError diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index 2fc260bd1..5d73e4597 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -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 @@ -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 @@ -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 diff --git a/lib/view_component/inline_template.rb b/lib/view_component/inline_template.rb new file mode 100644 index 000000000..4a52172cf --- /dev/null +++ b/lib/view_component/inline_template.rb @@ -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 diff --git a/test/sandbox/test/inline_template_test.rb b/test/sandbox/test/inline_template_test.rb new file mode 100644 index 000000000..724dbd243 --- /dev/null +++ b/test/sandbox/test/inline_template_test.rb @@ -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 +

Hello, <%= name %>!

+ ERB + + def initialize(name) + @name = name + end + end + + class InlineRaiseErbComponent < ViewComponent::Base + include ViewComponent::InlineTemplate + + attr_reader :name + + erb_template <<~ERB +

Hello, <%= raise ArgumentError, "oh no" %>!

+ ERB + + def initialize(name) + @name = name + end + end + + class InlineErbSubclassComponent < InlineErbComponent + erb_template <<~ERB +

Hey, <%= name %>!

+ 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