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.

Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +582 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +1000 -0
  5. data/app/controllers/view_components_controller.rb +71 -0
  6. data/app/views/view_components/index.html.erb +8 -0
  7. data/app/views/view_components/preview.html.erb +1 -0
  8. data/app/views/view_components/previews.html.erb +6 -0
  9. data/lib/rails/generators/component/USAGE +13 -0
  10. data/lib/rails/generators/component/component_generator.rb +42 -0
  11. data/lib/rails/generators/component/templates/component.rb.tt +7 -0
  12. data/lib/rails/generators/erb/component_generator.rb +30 -0
  13. data/lib/rails/generators/erb/templates/component.html.erb.tt +1 -0
  14. data/lib/rails/generators/haml/component_generator.rb +30 -0
  15. data/lib/rails/generators/haml/templates/component.html.haml.tt +1 -0
  16. data/lib/rails/generators/rspec/component_generator.rb +19 -0
  17. data/lib/rails/generators/rspec/templates/component_spec.rb.tt +13 -0
  18. data/lib/rails/generators/slim/component_generator.rb +30 -0
  19. data/lib/rails/generators/slim/templates/component.html.slim.tt +1 -0
  20. data/lib/rails/generators/test_unit/component_generator.rb +20 -0
  21. data/lib/rails/generators/test_unit/templates/component_test.rb.tt +10 -0
  22. data/lib/view_component.rb +14 -0
  23. data/lib/view_component/base.rb +458 -0
  24. data/lib/view_component/collection.rb +41 -0
  25. data/lib/view_component/compile_cache.rb +24 -0
  26. data/lib/view_component/engine.rb +101 -0
  27. data/lib/view_component/preview.rb +111 -0
  28. data/lib/view_component/preview_template_error.rb +6 -0
  29. data/lib/view_component/previewable.rb +38 -0
  30. data/lib/view_component/render_component_helper.rb +9 -0
  31. data/lib/view_component/render_component_to_string_helper.rb +9 -0
  32. data/lib/view_component/render_monkey_patch.rb +13 -0
  33. data/lib/view_component/render_to_string_monkey_patch.rb +13 -0
  34. data/lib/view_component/rendering_component_helper.rb +9 -0
  35. data/lib/view_component/rendering_monkey_patch.rb +13 -0
  36. data/lib/view_component/slot.rb +7 -0
  37. data/lib/view_component/slotable.rb +121 -0
  38. data/lib/view_component/template_error.rb +9 -0
  39. data/lib/view_component/test_case.rb +9 -0
  40. data/lib/view_component/test_helpers.rb +49 -0
  41. data/lib/view_component/version.rb +11 -0
  42. 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,6 @@
1
+ <h3><%= @preview.preview_name.titleize %></h3>
2
+ <ul>
3
+ <% @preview.examples.each do |example| %>
4
+ <li><%= link_to example, preview_view_component_path("#{@preview.preview_name}/#{example}") %></li>
5
+ <% end %>
6
+ </ul>
@@ -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,7 @@
1
+ class <%= class_name %>Component < <%= parent_class %>
2
+ <%- if initialize_signature -%>
3
+ def initialize(<%= initialize_signature %>)
4
+ <%= initialize_body %>
5
+ end
6
+ <%- end -%>
7
+ 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