view_component 2.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of view_component might be problematic. Click here for more details.
- checksums.yaml +7 -0
- data/CHANGELOG.md +582 -0
- data/LICENSE.txt +21 -0
- data/README.md +1000 -0
- data/app/controllers/view_components_controller.rb +71 -0
- data/app/views/view_components/index.html.erb +8 -0
- data/app/views/view_components/preview.html.erb +1 -0
- data/app/views/view_components/previews.html.erb +6 -0
- data/lib/rails/generators/component/USAGE +13 -0
- data/lib/rails/generators/component/component_generator.rb +42 -0
- data/lib/rails/generators/component/templates/component.rb.tt +7 -0
- data/lib/rails/generators/erb/component_generator.rb +30 -0
- data/lib/rails/generators/erb/templates/component.html.erb.tt +1 -0
- data/lib/rails/generators/haml/component_generator.rb +30 -0
- data/lib/rails/generators/haml/templates/component.html.haml.tt +1 -0
- data/lib/rails/generators/rspec/component_generator.rb +19 -0
- data/lib/rails/generators/rspec/templates/component_spec.rb.tt +13 -0
- data/lib/rails/generators/slim/component_generator.rb +30 -0
- data/lib/rails/generators/slim/templates/component.html.slim.tt +1 -0
- data/lib/rails/generators/test_unit/component_generator.rb +20 -0
- data/lib/rails/generators/test_unit/templates/component_test.rb.tt +10 -0
- data/lib/view_component.rb +14 -0
- data/lib/view_component/base.rb +458 -0
- data/lib/view_component/collection.rb +41 -0
- data/lib/view_component/compile_cache.rb +24 -0
- data/lib/view_component/engine.rb +101 -0
- data/lib/view_component/preview.rb +111 -0
- data/lib/view_component/preview_template_error.rb +6 -0
- data/lib/view_component/previewable.rb +38 -0
- data/lib/view_component/render_component_helper.rb +9 -0
- data/lib/view_component/render_component_to_string_helper.rb +9 -0
- data/lib/view_component/render_monkey_patch.rb +13 -0
- data/lib/view_component/render_to_string_monkey_patch.rb +13 -0
- data/lib/view_component/rendering_component_helper.rb +9 -0
- data/lib/view_component/rendering_monkey_patch.rb +13 -0
- data/lib/view_component/slot.rb +7 -0
- data/lib/view_component/slotable.rb +121 -0
- data/lib/view_component/template_error.rb +9 -0
- data/lib/view_component/test_case.rb +9 -0
- data/lib/view_component/test_helpers.rb +49 -0
- data/lib/view_component/version.rb +11 -0
- metadata +244 -0
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/application_controller"
|
4
|
+
|
5
|
+
class ViewComponentsController < Rails::ApplicationController # :nodoc:
|
6
|
+
prepend_view_path File.expand_path("../views", __dir__)
|
7
|
+
|
8
|
+
around_action :set_locale, only: :previews
|
9
|
+
before_action :find_preview, only: :previews
|
10
|
+
before_action :require_local!, unless: :show_previews?
|
11
|
+
|
12
|
+
if respond_to?(:content_security_policy)
|
13
|
+
content_security_policy(false)
|
14
|
+
end
|
15
|
+
|
16
|
+
def index
|
17
|
+
@previews = ViewComponent::Preview.all
|
18
|
+
@page_title = "Component Previews"
|
19
|
+
end
|
20
|
+
|
21
|
+
def previews
|
22
|
+
if params[:path] == @preview.preview_name
|
23
|
+
@page_title = "Component Previews for #{@preview.preview_name}"
|
24
|
+
render "view_components/previews"
|
25
|
+
else
|
26
|
+
prepend_application_view_paths
|
27
|
+
prepend_preview_examples_view_path
|
28
|
+
@example_name = File.basename(params[:path])
|
29
|
+
@render_args = @preview.render_args(@example_name, params: params.permit!)
|
30
|
+
layout = @render_args[:layout]
|
31
|
+
template = @render_args[:template]
|
32
|
+
locals = @render_args[:locals]
|
33
|
+
opts = {}
|
34
|
+
opts[:layout] = layout if layout.present?
|
35
|
+
opts[:locals] = locals if locals.present?
|
36
|
+
render template, opts # rubocop:disable GitHub/RailsControllerRenderLiteral
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def show_previews? # :doc:
|
43
|
+
ViewComponent::Base.show_previews
|
44
|
+
end
|
45
|
+
|
46
|
+
def find_preview # :doc:
|
47
|
+
candidates = []
|
48
|
+
params[:path].to_s.scan(%r{/|$}) { candidates << $` }
|
49
|
+
preview = candidates.detect { |candidate| ViewComponent::Preview.exists?(candidate) }
|
50
|
+
|
51
|
+
if preview
|
52
|
+
@preview = ViewComponent::Preview.find(preview)
|
53
|
+
else
|
54
|
+
raise AbstractController::ActionNotFound, "Component preview '#{params[:path]}' not found"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def set_locale
|
59
|
+
I18n.with_locale(params[:locale] || I18n.default_locale) do
|
60
|
+
yield
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def prepend_application_view_paths
|
65
|
+
prepend_view_path Rails.root.join("app/views") if defined?(Rails.root)
|
66
|
+
end
|
67
|
+
|
68
|
+
def prepend_preview_examples_view_path
|
69
|
+
prepend_view_path(ViewComponent::Base.preview_paths)
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,8 @@
|
|
1
|
+
<% @previews.each do |preview| %>
|
2
|
+
<h3><%= link_to preview.preview_name.titleize, preview_view_component_path(preview.preview_name) %></h3>
|
3
|
+
<ul>
|
4
|
+
<% preview.examples.each do |preview_example| %>
|
5
|
+
<li><%= link_to preview_example, preview_view_component_path("#{preview.preview_name}/#{preview_example}") %></li>
|
6
|
+
<% end %>
|
7
|
+
</ul>
|
8
|
+
<% end %>
|
@@ -0,0 +1 @@
|
|
1
|
+
<%= render(@render_args[:component], @render_args[:args], &@render_args[:block])%>
|
@@ -0,0 +1,13 @@
|
|
1
|
+
Description:
|
2
|
+
============
|
3
|
+
Creates a new component and test.
|
4
|
+
Pass the component name, either CamelCased or under_scored, and an optional list of attributes as arguments.
|
5
|
+
|
6
|
+
Example:
|
7
|
+
========
|
8
|
+
bin/rails generate component Profile name age
|
9
|
+
|
10
|
+
creates a Profile component and test:
|
11
|
+
Component: app/components/profile_component.rb
|
12
|
+
Template: app/components/profile_component.html.erb
|
13
|
+
Test: test/components/profile_component_test.rb
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Rails
|
4
|
+
module Generators
|
5
|
+
class ComponentGenerator < Rails::Generators::NamedBase
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
7
|
+
|
8
|
+
argument :attributes, type: :array, default: [], banner: "attribute"
|
9
|
+
check_class_collision suffix: "Component"
|
10
|
+
|
11
|
+
def create_component_file
|
12
|
+
template "component.rb", File.join("app/components", class_path, "#{file_name}_component.rb")
|
13
|
+
end
|
14
|
+
|
15
|
+
hook_for :test_framework
|
16
|
+
|
17
|
+
hook_for :template_engine do |instance, template_engine|
|
18
|
+
instance.invoke template_engine, [instance.name]
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def file_name
|
24
|
+
@_file_name ||= super.sub(/_component\z/i, "")
|
25
|
+
end
|
26
|
+
|
27
|
+
def parent_class
|
28
|
+
defined?(ApplicationComponent) ? "ApplicationComponent" : "ViewComponent::Base"
|
29
|
+
end
|
30
|
+
|
31
|
+
def initialize_signature
|
32
|
+
return if attributes.blank?
|
33
|
+
|
34
|
+
attributes.map { |attr| "#{attr.name}:" }.join(", ")
|
35
|
+
end
|
36
|
+
|
37
|
+
def initialize_body
|
38
|
+
attributes.map { |attr| "@#{attr.name} = #{attr.name}" }.join("\n ")
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/generators/erb"
|
4
|
+
|
5
|
+
module Erb
|
6
|
+
module Generators
|
7
|
+
class ComponentGenerator < Base
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
9
|
+
class_option :sidecar, type: :boolean, default: false
|
10
|
+
|
11
|
+
def copy_view_file
|
12
|
+
template "component.html.erb", destination
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def destination
|
18
|
+
if options["sidecar"]
|
19
|
+
File.join("app/components", class_path, "#{file_name}_component", "#{file_name}_component.html.erb")
|
20
|
+
else
|
21
|
+
File.join("app/components", class_path, "#{file_name}_component.html.erb")
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def file_name
|
26
|
+
@_file_name ||= super.sub(/_component\z/i, "")
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
<div>Add <%= class_name %> template here</div>
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/generators/erb/component_generator"
|
4
|
+
|
5
|
+
module Haml
|
6
|
+
module Generators
|
7
|
+
class ComponentGenerator < Erb::Generators::ComponentGenerator
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
9
|
+
class_option :sidecar, type: :boolean, default: false
|
10
|
+
|
11
|
+
def copy_view_file
|
12
|
+
template "component.html.haml", destination
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def destination
|
18
|
+
if options["sidecar"]
|
19
|
+
File.join("app/components", class_path, "#{file_name}_component", "#{file_name}_component.html.haml")
|
20
|
+
else
|
21
|
+
File.join("app/components", class_path, "#{file_name}_component.html.haml")
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def file_name
|
26
|
+
@_file_name ||= super.sub(/_component\z/i, "")
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
%div Add <%= class_name %> template here
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Rspec
|
4
|
+
module Generators
|
5
|
+
class ComponentGenerator < ::Rails::Generators::NamedBase
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
7
|
+
|
8
|
+
def create_test_file
|
9
|
+
template "component_spec.rb", File.join("spec/components", class_path, "#{file_name}_component_spec.rb")
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
def file_name
|
15
|
+
@_file_name ||= super.sub(/_component\z/i, "")
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require "rails_helper"
|
2
|
+
|
3
|
+
RSpec.describe <%= class_name %>Component, type: :component do
|
4
|
+
pending "add some examples to (or delete) #{__FILE__}"
|
5
|
+
|
6
|
+
# it "renders something useful" do
|
7
|
+
# expect(
|
8
|
+
# render_inline(described_class.new(attr: "value")) { "Hello, components!" }.css("p").to_html
|
9
|
+
# ).to include(
|
10
|
+
# "Hello, components!"
|
11
|
+
# )
|
12
|
+
# end
|
13
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/generators/erb/component_generator"
|
4
|
+
|
5
|
+
module Slim
|
6
|
+
module Generators
|
7
|
+
class ComponentGenerator < Erb::Generators::ComponentGenerator
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
9
|
+
class_option :sidecar, type: :boolean, default: false
|
10
|
+
|
11
|
+
def copy_view_file
|
12
|
+
template "component.html.slim", destination
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def destination
|
18
|
+
if options["sidecar"]
|
19
|
+
File.join("app/components", class_path, "#{file_name}_component", "#{file_name}_component.html.slim")
|
20
|
+
else
|
21
|
+
File.join("app/components", class_path, "#{file_name}_component.html.slim")
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def file_name
|
26
|
+
@_file_name ||= super.sub(/_component\z/i, "")
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
div Add <%= class_name %> template here
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TestUnit
|
4
|
+
module Generators
|
5
|
+
class ComponentGenerator < ::Rails::Generators::NamedBase
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
7
|
+
check_class_collision suffix: "ComponentTest"
|
8
|
+
|
9
|
+
def create_test_file
|
10
|
+
template "component_test.rb", File.join("test/components", class_path, "#{file_name}_component_test.rb")
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def file_name
|
16
|
+
@_file_name ||= super.sub(/_component\z/i, "")
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
|
3
|
+
class <%= class_name %>ComponentTest < ViewComponent::TestCase
|
4
|
+
test "component renders something useful" do
|
5
|
+
# assert_equal(
|
6
|
+
# %(<span>Hello, components!</span>),
|
7
|
+
# render_inline(<%= class_name %>Component.new(message: "Hello, components!")).css("span").to_html
|
8
|
+
# )
|
9
|
+
end
|
10
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "action_view"
|
3
|
+
require "active_support/dependencies/autoload"
|
4
|
+
|
5
|
+
module ViewComponent
|
6
|
+
extend ActiveSupport::Autoload
|
7
|
+
|
8
|
+
autoload :Base
|
9
|
+
autoload :Preview
|
10
|
+
autoload :PreviewTemplateError
|
11
|
+
autoload :TestHelpers
|
12
|
+
autoload :TestCase
|
13
|
+
autoload :TemplateError
|
14
|
+
end
|
@@ -0,0 +1,458 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "action_view"
|
4
|
+
require "active_support/configurable"
|
5
|
+
require "view_component/collection"
|
6
|
+
require "view_component/compile_cache"
|
7
|
+
require "view_component/previewable"
|
8
|
+
require "view_component/slotable"
|
9
|
+
|
10
|
+
module ViewComponent
|
11
|
+
class Base < ActionView::Base
|
12
|
+
include ActiveSupport::Configurable
|
13
|
+
include ViewComponent::Previewable
|
14
|
+
|
15
|
+
# For CSRF authenticity tokens in forms
|
16
|
+
delegate :form_authenticity_token, :protect_against_forgery?, :config, to: :helpers
|
17
|
+
|
18
|
+
class_attribute :content_areas
|
19
|
+
self.content_areas = [] # class_attribute:default doesn't work until Rails 5.2
|
20
|
+
|
21
|
+
# Hash of registered Slots
|
22
|
+
class_attribute :slots
|
23
|
+
self.slots = {}
|
24
|
+
|
25
|
+
# Entrypoint for rendering components.
|
26
|
+
#
|
27
|
+
# view_context: ActionView context from calling view
|
28
|
+
# block: optional block to be captured within the view context
|
29
|
+
#
|
30
|
+
# returns HTML that has been escaped by the respective template handler
|
31
|
+
#
|
32
|
+
# Example subclass:
|
33
|
+
#
|
34
|
+
# app/components/my_component.rb:
|
35
|
+
# class MyComponent < ViewComponent::Base
|
36
|
+
# def initialize(title:)
|
37
|
+
# @title = title
|
38
|
+
# end
|
39
|
+
# end
|
40
|
+
#
|
41
|
+
# app/components/my_component.html.erb
|
42
|
+
# <span title="<%= @title %>">Hello, <%= content %>!</span>
|
43
|
+
#
|
44
|
+
# In use:
|
45
|
+
# <%= render MyComponent.new(title: "greeting") do %>world<% end %>
|
46
|
+
# returns:
|
47
|
+
# <span title="greeting">Hello, world!</span>
|
48
|
+
#
|
49
|
+
def render_in(view_context, &block)
|
50
|
+
self.class.compile(raise_errors: true)
|
51
|
+
|
52
|
+
@view_context = view_context
|
53
|
+
@lookup_context ||= view_context.lookup_context
|
54
|
+
|
55
|
+
# required for path helpers in older Rails versions
|
56
|
+
@view_renderer ||= view_context.view_renderer
|
57
|
+
|
58
|
+
# For content_for
|
59
|
+
@view_flow ||= view_context.view_flow
|
60
|
+
|
61
|
+
# For i18n
|
62
|
+
@virtual_path ||= virtual_path
|
63
|
+
|
64
|
+
# For template variants (+phone, +desktop, etc.)
|
65
|
+
@variant = @lookup_context.variants.first
|
66
|
+
|
67
|
+
# For caching, such as #cache_if
|
68
|
+
@current_template = nil unless defined?(@current_template)
|
69
|
+
old_current_template = @current_template
|
70
|
+
@current_template = self
|
71
|
+
|
72
|
+
# Assign captured content passed to component as a block to @content
|
73
|
+
@content = view_context.capture(self, &block) if block_given?
|
74
|
+
|
75
|
+
before_render
|
76
|
+
|
77
|
+
if render?
|
78
|
+
send(self.class.call_method_name(@variant))
|
79
|
+
else
|
80
|
+
""
|
81
|
+
end
|
82
|
+
ensure
|
83
|
+
@current_template = old_current_template
|
84
|
+
end
|
85
|
+
|
86
|
+
def before_render
|
87
|
+
before_render_check
|
88
|
+
end
|
89
|
+
|
90
|
+
def before_render_check
|
91
|
+
# noop
|
92
|
+
end
|
93
|
+
|
94
|
+
def render?
|
95
|
+
true
|
96
|
+
end
|
97
|
+
|
98
|
+
def initialize(*); end
|
99
|
+
|
100
|
+
# If trying to render a partial or template inside a component,
|
101
|
+
# pass the render call to the parent view_context.
|
102
|
+
def render(options = {}, args = {}, &block)
|
103
|
+
if options.is_a?(String) || (options.is_a?(Hash) && options.has_key?(:partial))
|
104
|
+
view_context.render(options, args, &block)
|
105
|
+
else
|
106
|
+
super
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def controller
|
111
|
+
@controller ||= view_context.controller
|
112
|
+
end
|
113
|
+
|
114
|
+
# Provides a proxy to access helper methods from the context of the current controller
|
115
|
+
def helpers
|
116
|
+
@helpers ||= controller.view_context
|
117
|
+
end
|
118
|
+
|
119
|
+
# Removes the first part of the path and the extension.
|
120
|
+
def virtual_path
|
121
|
+
self.class.source_location.gsub(%r{(.*app/components)|(\.rb)}, "")
|
122
|
+
end
|
123
|
+
|
124
|
+
# For caching, such as #cache_if
|
125
|
+
def view_cache_dependencies
|
126
|
+
[]
|
127
|
+
end
|
128
|
+
|
129
|
+
# For caching, such as #cache_if
|
130
|
+
def format
|
131
|
+
@variant
|
132
|
+
end
|
133
|
+
|
134
|
+
# Assign the provided content to the content area accessor
|
135
|
+
def with(area, content = nil, &block)
|
136
|
+
unless content_areas.include?(area)
|
137
|
+
raise ArgumentError.new "Unknown content_area '#{area}' - expected one of '#{content_areas}'"
|
138
|
+
end
|
139
|
+
|
140
|
+
if block_given?
|
141
|
+
content = view_context.capture(&block)
|
142
|
+
end
|
143
|
+
|
144
|
+
instance_variable_set("@#{area}".to_sym, content)
|
145
|
+
nil
|
146
|
+
end
|
147
|
+
|
148
|
+
private
|
149
|
+
|
150
|
+
# Exposes the current request to the component.
|
151
|
+
# Use sparingly as doing so introduces coupling
|
152
|
+
# that inhibits encapsulation & reuse.
|
153
|
+
def request
|
154
|
+
@request ||= controller.request
|
155
|
+
end
|
156
|
+
|
157
|
+
attr_reader :content, :view_context
|
158
|
+
|
159
|
+
# The controller used for testing components.
|
160
|
+
# Defaults to ApplicationController. This should be set early
|
161
|
+
# in the initialization process and should be set to a string.
|
162
|
+
mattr_accessor :test_controller
|
163
|
+
@@test_controller = "ApplicationController"
|
164
|
+
|
165
|
+
# Configure if render monkey patches should be included or not in Rails <6.1.
|
166
|
+
mattr_accessor :render_monkey_patch_enabled, instance_writer: false, default: true
|
167
|
+
|
168
|
+
class << self
|
169
|
+
attr_accessor :source_location
|
170
|
+
|
171
|
+
# Render a component collection.
|
172
|
+
def with_collection(collection, **args)
|
173
|
+
Collection.new(self, collection, **args)
|
174
|
+
end
|
175
|
+
|
176
|
+
# Provide identifier for ActionView template annotations
|
177
|
+
def short_identifier
|
178
|
+
@short_identifier ||= defined?(Rails.root) ? source_location.sub("#{Rails.root}/", "") : source_location
|
179
|
+
end
|
180
|
+
|
181
|
+
def inherited(child)
|
182
|
+
# If we're in Rails, add application url_helpers to the component context
|
183
|
+
if defined?(Rails)
|
184
|
+
child.include Rails.application.routes.url_helpers unless child < Rails.application.routes.url_helpers
|
185
|
+
end
|
186
|
+
|
187
|
+
# Derive the source location of the component Ruby file from the call stack.
|
188
|
+
# We need to ignore `inherited` frames here as they indicate that `inherited`
|
189
|
+
# has been re-defined by the consuming application, likely in ApplicationComponent.
|
190
|
+
child.source_location = caller_locations(1, 10).reject { |l| l.label == "inherited" }[0].absolute_path
|
191
|
+
|
192
|
+
# Clone slot configuration into child class
|
193
|
+
# see #test_slots_pollution
|
194
|
+
child.slots = self.slots.clone
|
195
|
+
|
196
|
+
super
|
197
|
+
end
|
198
|
+
|
199
|
+
def call_method_name(variant)
|
200
|
+
if variant.present? && variants.include?(variant)
|
201
|
+
"call_#{variant}"
|
202
|
+
else
|
203
|
+
"call"
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
def compiled?
|
208
|
+
CompileCache.compiled?(self)
|
209
|
+
end
|
210
|
+
|
211
|
+
# Compile templates to instance methods, assuming they haven't been compiled already.
|
212
|
+
#
|
213
|
+
# Do as much work as possible in this step, as doing so reduces the amount
|
214
|
+
# of work done each time a component is rendered.
|
215
|
+
def compile(raise_errors: false)
|
216
|
+
return if compiled?
|
217
|
+
|
218
|
+
if template_errors.present?
|
219
|
+
raise ViewComponent::TemplateError.new(template_errors) if raise_errors
|
220
|
+
return false
|
221
|
+
end
|
222
|
+
|
223
|
+
if instance_methods(false).include?(:before_render_check)
|
224
|
+
ActiveSupport::Deprecation.warn(
|
225
|
+
"`before_render_check` will be removed in v3.0.0. Use `before_render` instead."
|
226
|
+
)
|
227
|
+
end
|
228
|
+
|
229
|
+
# Remove any existing singleton methods,
|
230
|
+
# as Ruby warns when redefining a method.
|
231
|
+
remove_possible_singleton_method(:variants)
|
232
|
+
remove_possible_singleton_method(:collection_parameter)
|
233
|
+
remove_possible_singleton_method(:collection_counter_parameter)
|
234
|
+
remove_possible_singleton_method(:counter_argument_present?)
|
235
|
+
|
236
|
+
define_singleton_method(:variants) do
|
237
|
+
templates.map { |template| template[:variant] } + variants_from_inline_calls(inline_calls)
|
238
|
+
end
|
239
|
+
|
240
|
+
define_singleton_method(:collection_parameter) do
|
241
|
+
if provided_collection_parameter
|
242
|
+
provided_collection_parameter
|
243
|
+
else
|
244
|
+
name.demodulize.underscore.chomp("_component").to_sym
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
define_singleton_method(:collection_counter_parameter) do
|
249
|
+
"#{collection_parameter}_counter".to_sym
|
250
|
+
end
|
251
|
+
|
252
|
+
define_singleton_method(:counter_argument_present?) do
|
253
|
+
instance_method(:initialize).parameters.map(&:second).include?(collection_counter_parameter)
|
254
|
+
end
|
255
|
+
|
256
|
+
validate_collection_parameter! if raise_errors
|
257
|
+
|
258
|
+
# If template name annotations are turned on, a line is dynamically
|
259
|
+
# added with a comment. In this case, we want to return a different
|
260
|
+
# starting line number so errors that are raised will point to the
|
261
|
+
# correct line in the component template.
|
262
|
+
line_number =
|
263
|
+
if ActionView::Base.respond_to?(:annotate_rendered_view_with_filenames) &&
|
264
|
+
ActionView::Base.annotate_rendered_view_with_filenames
|
265
|
+
-2
|
266
|
+
else
|
267
|
+
-1
|
268
|
+
end
|
269
|
+
|
270
|
+
templates.each do |template|
|
271
|
+
# Remove existing compiled template methods,
|
272
|
+
# as Ruby warns when redefining a method.
|
273
|
+
method_name = call_method_name(template[:variant])
|
274
|
+
undef_method(method_name.to_sym) if instance_methods.include?(method_name.to_sym)
|
275
|
+
|
276
|
+
class_eval <<-RUBY, template[:path], line_number
|
277
|
+
def #{method_name}
|
278
|
+
@output_buffer = ActionView::OutputBuffer.new
|
279
|
+
#{compiled_template(template[:path])}
|
280
|
+
end
|
281
|
+
RUBY
|
282
|
+
end
|
283
|
+
|
284
|
+
CompileCache.register self
|
285
|
+
end
|
286
|
+
|
287
|
+
# we'll eventually want to update this to support other types
|
288
|
+
def type
|
289
|
+
"text/html"
|
290
|
+
end
|
291
|
+
|
292
|
+
def format
|
293
|
+
:html
|
294
|
+
end
|
295
|
+
|
296
|
+
def identifier
|
297
|
+
source_location
|
298
|
+
end
|
299
|
+
|
300
|
+
def with_content_areas(*areas)
|
301
|
+
if areas.include?(:content)
|
302
|
+
raise ArgumentError.new ":content is a reserved content area name. Please use another name, such as ':body'"
|
303
|
+
end
|
304
|
+
attr_reader(*areas)
|
305
|
+
self.content_areas = areas
|
306
|
+
end
|
307
|
+
|
308
|
+
# Support overriding collection parameter name
|
309
|
+
def with_collection_parameter(param)
|
310
|
+
@provided_collection_parameter = param
|
311
|
+
end
|
312
|
+
|
313
|
+
# Ensure the component initializer accepts the
|
314
|
+
# collection parameter. By default, we do not
|
315
|
+
# validate that the default parameter name
|
316
|
+
# is accepted, as support for collection
|
317
|
+
# rendering is optional.
|
318
|
+
def validate_collection_parameter!(validate_default: false)
|
319
|
+
parameter = validate_default ? collection_parameter : provided_collection_parameter
|
320
|
+
|
321
|
+
return unless parameter
|
322
|
+
return if initialize_parameters.map(&:last).include?(parameter)
|
323
|
+
|
324
|
+
# If Ruby cannot parse the component class, then the initalize
|
325
|
+
# parameters will be empty and ViewComponent will not be able to render
|
326
|
+
# the component.
|
327
|
+
if initialize_parameters.empty?
|
328
|
+
raise ArgumentError.new(
|
329
|
+
"#{self} initializer is empty or invalid."
|
330
|
+
)
|
331
|
+
end
|
332
|
+
|
333
|
+
raise ArgumentError.new(
|
334
|
+
"#{self} initializer must accept " \
|
335
|
+
"`#{parameter}` collection parameter."
|
336
|
+
)
|
337
|
+
end
|
338
|
+
|
339
|
+
private
|
340
|
+
|
341
|
+
def initialize_parameters
|
342
|
+
instance_method(:initialize).parameters
|
343
|
+
end
|
344
|
+
|
345
|
+
def provided_collection_parameter
|
346
|
+
@provided_collection_parameter ||= nil
|
347
|
+
end
|
348
|
+
|
349
|
+
def compiled_template(file_path)
|
350
|
+
handler = ActionView::Template.handler_for_extension(File.extname(file_path).gsub(".", ""))
|
351
|
+
template = File.read(file_path)
|
352
|
+
|
353
|
+
if handler.method(:call).parameters.length > 1
|
354
|
+
handler.call(self, template)
|
355
|
+
else
|
356
|
+
handler.call(OpenStruct.new(source: template, identifier: identifier, type: type))
|
357
|
+
end
|
358
|
+
end
|
359
|
+
|
360
|
+
def inline_calls
|
361
|
+
@inline_calls ||=
|
362
|
+
begin
|
363
|
+
# Fetch only ViewComponent ancestor classes to limit the scope of
|
364
|
+
# finding inline calls
|
365
|
+
view_component_ancestors =
|
366
|
+
ancestors.take_while { |ancestor| ancestor != ViewComponent::Base } - included_modules
|
367
|
+
|
368
|
+
view_component_ancestors.flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call/) }.uniq
|
369
|
+
end
|
370
|
+
end
|
371
|
+
|
372
|
+
def inline_calls_defined_on_self
|
373
|
+
@inline_calls_defined_on_self ||= instance_methods(false).grep(/^call/)
|
374
|
+
end
|
375
|
+
|
376
|
+
def matching_views_in_source_location
|
377
|
+
return [] unless source_location
|
378
|
+
|
379
|
+
location_without_extension = source_location.chomp(File.extname(source_location))
|
380
|
+
|
381
|
+
extenstions = ActionView::Template.template_handler_extensions.join(",")
|
382
|
+
|
383
|
+
# view files in the same directory as te component
|
384
|
+
sidecar_files = Dir["#{location_without_extension}.*{#{extenstions}}"]
|
385
|
+
|
386
|
+
# view files in a directory named like the component
|
387
|
+
directory = File.dirname(source_location)
|
388
|
+
filename = File.basename(source_location, ".rb")
|
389
|
+
component_name = name.demodulize.underscore
|
390
|
+
|
391
|
+
sidecar_directory_files = Dir["#{directory}/#{component_name}/#{filename}.*{#{extenstions}}"]
|
392
|
+
|
393
|
+
(sidecar_files - [source_location] + sidecar_directory_files)
|
394
|
+
end
|
395
|
+
|
396
|
+
def templates
|
397
|
+
@templates ||=
|
398
|
+
matching_views_in_source_location.each_with_object([]) do |path, memo|
|
399
|
+
pieces = File.basename(path).split(".")
|
400
|
+
|
401
|
+
memo << {
|
402
|
+
path: path,
|
403
|
+
variant: pieces.second.split("+").second&.to_sym,
|
404
|
+
handler: pieces.last
|
405
|
+
}
|
406
|
+
end
|
407
|
+
end
|
408
|
+
|
409
|
+
def template_errors
|
410
|
+
@template_errors ||=
|
411
|
+
begin
|
412
|
+
errors = []
|
413
|
+
|
414
|
+
if (templates + inline_calls).empty?
|
415
|
+
errors << "Could not find a template file or inline render method for #{self}."
|
416
|
+
end
|
417
|
+
|
418
|
+
if templates.count { |template| template[:variant].nil? } > 1
|
419
|
+
errors << "More than one template found for #{self}. There can only be one default template file per component."
|
420
|
+
end
|
421
|
+
|
422
|
+
invalid_variants = templates
|
423
|
+
.group_by { |template| template[:variant] }
|
424
|
+
.map { |variant, grouped| variant if grouped.length > 1 }
|
425
|
+
.compact
|
426
|
+
.sort
|
427
|
+
|
428
|
+
unless invalid_variants.empty?
|
429
|
+
errors << "More than one template found for #{'variant'.pluralize(invalid_variants.count)} #{invalid_variants.map { |v| "'#{v}'" }.to_sentence} in #{self}. There can only be one template file per variant."
|
430
|
+
end
|
431
|
+
|
432
|
+
if templates.find { |template| template[:variant].nil? } && inline_calls_defined_on_self.include?(:call)
|
433
|
+
errors << "Template file and inline render method found for #{self}. There can only be a template file or inline render method per component."
|
434
|
+
end
|
435
|
+
|
436
|
+
duplicate_template_file_and_inline_variant_calls =
|
437
|
+
templates.pluck(:variant) & variants_from_inline_calls(inline_calls_defined_on_self)
|
438
|
+
|
439
|
+
unless duplicate_template_file_and_inline_variant_calls.empty?
|
440
|
+
count = duplicate_template_file_and_inline_variant_calls.count
|
441
|
+
|
442
|
+
errors << "Template #{'file'.pluralize(count)} and inline render #{'method'.pluralize(count)} found for #{'variant'.pluralize(count)} #{duplicate_template_file_and_inline_variant_calls.map { |v| "'#{v}'" }.to_sentence} in #{self}. There can only be a template file or inline render method per variant."
|
443
|
+
end
|
444
|
+
|
445
|
+
errors
|
446
|
+
end
|
447
|
+
end
|
448
|
+
|
449
|
+
def variants_from_inline_calls(calls)
|
450
|
+
calls.reject { |call| call == :call }.map do |variant_call|
|
451
|
+
variant_call.to_s.sub("call_", "").to_sym
|
452
|
+
end
|
453
|
+
end
|
454
|
+
end
|
455
|
+
|
456
|
+
ActiveSupport.run_load_hooks(:view_component, self)
|
457
|
+
end
|
458
|
+
end
|