view_component 2.19.1 → 2.23.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.

@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class <%= class_name %>Component < <%= parent_class %>
2
4
  <%- if initialize_signature -%>
3
5
  def initialize(<%= initialize_signature %>)
@@ -1,7 +1,7 @@
1
1
  require "test_helper"
2
2
 
3
3
  class <%= class_name %>ComponentTest < ViewComponent::TestCase
4
- test "component renders something useful" do
4
+ def test_component_renders_something_useful
5
5
  # assert_equal(
6
6
  # %(<span>Hello, components!</span>),
7
7
  # render_inline(<%= class_name %>Component.new(message: "Hello, components!")).css("span").to_html
@@ -6,6 +6,7 @@ module ViewComponent
6
6
  extend ActiveSupport::Autoload
7
7
 
8
8
  autoload :Base
9
+ autoload :Compiler
9
10
  autoload :Preview
10
11
  autoload :PreviewTemplateError
11
12
  autoload :TestHelpers
@@ -6,6 +6,7 @@ require "view_component/collection"
6
6
  require "view_component/compile_cache"
7
7
  require "view_component/previewable"
8
8
  require "view_component/slotable"
9
+ require "view_component/slotable_v2"
9
10
 
10
11
  module ViewComponent
11
12
  class Base < ActionView::Base
@@ -20,10 +21,6 @@ module ViewComponent
20
21
  class_attribute :content_areas
21
22
  self.content_areas = [] # class_attribute:default doesn't work until Rails 5.2
22
23
 
23
- # Hash of registered Slots
24
- class_attribute :slots
25
- self.slots = {}
26
-
27
24
  # Entrypoint for rendering components.
28
25
  #
29
26
  # view_context: ActionView context from calling view
@@ -64,7 +61,7 @@ module ViewComponent
64
61
  @virtual_path ||= virtual_path
65
62
 
66
63
  # For template variants (+phone, +desktop, etc.)
67
- @variant = @lookup_context.variants.first
64
+ @variant ||= @lookup_context.variants.first
68
65
 
69
66
  # For caching, such as #cache_if
70
67
  @current_template = nil unless defined?(@current_template)
@@ -77,7 +74,7 @@ module ViewComponent
77
74
  before_render
78
75
 
79
76
  if render?
80
- send(self.class.call_method_name(@variant))
77
+ render_template_for(@variant)
81
78
  else
82
79
  ""
83
80
  end
@@ -99,13 +96,16 @@ module ViewComponent
99
96
 
100
97
  def initialize(*); end
101
98
 
102
- # If trying to render a partial or template inside a component,
103
- # pass the render call to the parent view_context.
99
+ # Re-use original view_context if we're not rendering a component.
100
+ #
101
+ # This prevents an exception when rendering a partial inside of a component that has also been rendered outside
102
+ # of the component. This is due to the partials compiled template method existing in the parent `view_context`,
103
+ # and not the component's `view_context`.
104
104
  def render(options = {}, args = {}, &block)
105
- if options.is_a?(String) || (options.is_a?(Hash) && options.has_key?(:partial))
106
- view_context.render(options, args, &block)
107
- else
105
+ if options.is_a? ViewComponent::Base
108
106
  super
107
+ else
108
+ view_context.render(options, args, &block)
109
109
  end
110
110
  end
111
111
 
@@ -132,7 +132,10 @@ module ViewComponent
132
132
 
133
133
  # For caching, such as #cache_if
134
134
  def format
135
- @variant
135
+ # Ruby 2.6 throws a warning without checking `defined?`, 2.7 does not
136
+ if defined?(@variant)
137
+ @variant
138
+ end
136
139
  end
137
140
 
138
141
  # Assign the provided content to the content area accessor
@@ -149,6 +152,12 @@ module ViewComponent
149
152
  nil
150
153
  end
151
154
 
155
+ def with_variant(variant)
156
+ @variant = variant
157
+
158
+ self
159
+ end
160
+
152
161
  private
153
162
 
154
163
  # Exposes the current request to the component.
@@ -201,23 +210,11 @@ module ViewComponent
201
210
  # Removes the first part of the path and the extension.
202
211
  child.virtual_path = child.source_location.gsub(%r{(.*app/components)|(\.rb)}, "")
203
212
 
204
- # Clone slot configuration into child class
205
- # see #test_slots_pollution
206
- child.slots = self.slots.clone
207
-
208
213
  super
209
214
  end
210
215
 
211
- def call_method_name(variant)
212
- if variant.present? && variants.include?(variant)
213
- "call_#{variant}"
214
- else
215
- "call"
216
- end
217
- end
218
-
219
216
  def compiled?
220
- CompileCache.compiled?(self)
217
+ template_compiler.compiled?
221
218
  end
222
219
 
223
220
  # Compile templates to instance methods, assuming they haven't been compiled already.
@@ -225,75 +222,11 @@ module ViewComponent
225
222
  # Do as much work as possible in this step, as doing so reduces the amount
226
223
  # of work done each time a component is rendered.
227
224
  def compile(raise_errors: false)
228
- return if compiled?
229
-
230
- if template_errors.present?
231
- raise ViewComponent::TemplateError.new(template_errors) if raise_errors
232
- return false
233
- end
234
-
235
- if instance_methods(false).include?(:before_render_check)
236
- ActiveSupport::Deprecation.warn(
237
- "`before_render_check` will be removed in v3.0.0. Use `before_render` instead."
238
- )
239
- end
240
-
241
- # Remove any existing singleton methods,
242
- # as Ruby warns when redefining a method.
243
- remove_possible_singleton_method(:variants)
244
- remove_possible_singleton_method(:collection_parameter)
245
- remove_possible_singleton_method(:collection_counter_parameter)
246
- remove_possible_singleton_method(:counter_argument_present?)
247
-
248
- define_singleton_method(:variants) do
249
- templates.map { |template| template[:variant] } + variants_from_inline_calls(inline_calls)
250
- end
251
-
252
- define_singleton_method(:collection_parameter) do
253
- if provided_collection_parameter
254
- provided_collection_parameter
255
- else
256
- name.demodulize.underscore.chomp("_component").to_sym
257
- end
258
- end
259
-
260
- define_singleton_method(:collection_counter_parameter) do
261
- "#{collection_parameter}_counter".to_sym
262
- end
263
-
264
- define_singleton_method(:counter_argument_present?) do
265
- instance_method(:initialize).parameters.map(&:second).include?(collection_counter_parameter)
266
- end
267
-
268
- validate_collection_parameter! if raise_errors
269
-
270
- # If template name annotations are turned on, a line is dynamically
271
- # added with a comment. In this case, we want to return a different
272
- # starting line number so errors that are raised will point to the
273
- # correct line in the component template.
274
- line_number =
275
- if ActionView::Base.respond_to?(:annotate_rendered_view_with_filenames) &&
276
- ActionView::Base.annotate_rendered_view_with_filenames
277
- -2
278
- else
279
- -1
280
- end
281
-
282
- templates.each do |template|
283
- # Remove existing compiled template methods,
284
- # as Ruby warns when redefining a method.
285
- method_name = call_method_name(template[:variant])
286
- undef_method(method_name.to_sym) if instance_methods.include?(method_name.to_sym)
287
-
288
- class_eval <<-RUBY, template[:path], line_number
289
- def #{method_name}
290
- @output_buffer = ActionView::OutputBuffer.new
291
- #{compiled_template(template[:path])}
292
- end
293
- RUBY
294
- end
225
+ template_compiler.compile(raise_errors: raise_errors)
226
+ end
295
227
 
296
- CompileCache.register self
228
+ def template_compiler
229
+ @_template_compiler ||= Compiler.new(self)
297
230
  end
298
231
 
299
232
  # we'll eventually want to update this to support other types
@@ -357,112 +290,6 @@ module ViewComponent
357
290
  def provided_collection_parameter
358
291
  @provided_collection_parameter ||= nil
359
292
  end
360
-
361
- def compiled_template(file_path)
362
- handler = ActionView::Template.handler_for_extension(File.extname(file_path).gsub(".", ""))
363
- template = File.read(file_path)
364
-
365
- if handler.method(:call).parameters.length > 1
366
- handler.call(self, template)
367
- else
368
- handler.call(OpenStruct.new(source: template, identifier: identifier, type: type))
369
- end
370
- end
371
-
372
- def inline_calls
373
- @inline_calls ||=
374
- begin
375
- # Fetch only ViewComponent ancestor classes to limit the scope of
376
- # finding inline calls
377
- view_component_ancestors =
378
- ancestors.take_while { |ancestor| ancestor != ViewComponent::Base } - included_modules
379
-
380
- view_component_ancestors.flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call/) }.uniq
381
- end
382
- end
383
-
384
- def inline_calls_defined_on_self
385
- @inline_calls_defined_on_self ||= instance_methods(false).grep(/^call/)
386
- end
387
-
388
- def matching_views_in_source_location
389
- return [] unless source_location
390
-
391
- location_without_extension = source_location.chomp(File.extname(source_location))
392
-
393
- extensions = ActionView::Template.template_handler_extensions.join(",")
394
-
395
- # view files in the same directory as the component
396
- sidecar_files = Dir["#{location_without_extension}.*{#{extensions}}"]
397
-
398
- # view files in a directory named like the component
399
- directory = File.dirname(source_location)
400
- filename = File.basename(source_location, ".rb")
401
- component_name = name.demodulize.underscore
402
-
403
- sidecar_directory_files = Dir["#{directory}/#{component_name}/#{filename}.*{#{extensions}}"]
404
-
405
- (sidecar_files - [source_location] + sidecar_directory_files)
406
- end
407
-
408
- def templates
409
- @templates ||=
410
- matching_views_in_source_location.each_with_object([]) do |path, memo|
411
- pieces = File.basename(path).split(".")
412
-
413
- memo << {
414
- path: path,
415
- variant: pieces.second.split("+").second&.to_sym,
416
- handler: pieces.last
417
- }
418
- end
419
- end
420
-
421
- def template_errors
422
- @template_errors ||=
423
- begin
424
- errors = []
425
-
426
- if (templates + inline_calls).empty?
427
- errors << "Could not find a template file or inline render method for #{self}."
428
- end
429
-
430
- if templates.count { |template| template[:variant].nil? } > 1
431
- errors << "More than one template found for #{self}. There can only be one default template file per component."
432
- end
433
-
434
- invalid_variants = templates
435
- .group_by { |template| template[:variant] }
436
- .map { |variant, grouped| variant if grouped.length > 1 }
437
- .compact
438
- .sort
439
-
440
- unless invalid_variants.empty?
441
- 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."
442
- end
443
-
444
- if templates.find { |template| template[:variant].nil? } && inline_calls_defined_on_self.include?(:call)
445
- errors << "Template file and inline render method found for #{self}. There can only be a template file or inline render method per component."
446
- end
447
-
448
- duplicate_template_file_and_inline_variant_calls =
449
- templates.pluck(:variant) & variants_from_inline_calls(inline_calls_defined_on_self)
450
-
451
- unless duplicate_template_file_and_inline_variant_calls.empty?
452
- count = duplicate_template_file_and_inline_variant_calls.count
453
-
454
- 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."
455
- end
456
-
457
- errors
458
- end
459
- end
460
-
461
- def variants_from_inline_calls(calls)
462
- calls.reject { |call| call == :call }.map do |variant_call|
463
- variant_call.to_s.sub("call_", "").to_sym
464
- end
465
- end
466
293
  end
467
294
 
468
295
  ActiveSupport.run_load_hooks(:view_component, self)
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ class Compiler
5
+ def initialize(component_class)
6
+ @component_class = component_class
7
+ end
8
+
9
+ def compiled?
10
+ CompileCache.compiled?(component_class)
11
+ end
12
+
13
+ def compile(raise_errors: false)
14
+ return if compiled?
15
+
16
+ if template_errors.present?
17
+ raise ViewComponent::TemplateError.new(template_errors) if raise_errors
18
+ return false
19
+ end
20
+
21
+ if component_class.instance_methods(false).include?(:before_render_check)
22
+ ActiveSupport::Deprecation.warn(
23
+ "`before_render_check` will be removed in v3.0.0. Use `before_render` instead."
24
+ )
25
+ end
26
+
27
+ # Remove any existing singleton methods,
28
+ # as Ruby warns when redefining a method.
29
+ component_class.remove_possible_singleton_method(:collection_parameter)
30
+ component_class.remove_possible_singleton_method(:collection_counter_parameter)
31
+ component_class.remove_possible_singleton_method(:counter_argument_present?)
32
+
33
+ component_class.define_singleton_method(:collection_parameter) do
34
+ if provided_collection_parameter
35
+ provided_collection_parameter
36
+ else
37
+ name.demodulize.underscore.chomp("_component").to_sym
38
+ end
39
+ end
40
+
41
+ component_class.define_singleton_method(:collection_counter_parameter) do
42
+ "#{collection_parameter}_counter".to_sym
43
+ end
44
+
45
+ component_class.define_singleton_method(:counter_argument_present?) do
46
+ instance_method(:initialize).parameters.map(&:second).include?(collection_counter_parameter)
47
+ end
48
+
49
+ component_class.validate_collection_parameter! if raise_errors
50
+
51
+ templates.each do |template|
52
+ # Remove existing compiled template methods,
53
+ # as Ruby warns when redefining a method.
54
+ method_name = call_method_name(template[:variant])
55
+ component_class.send(:undef_method, method_name.to_sym) if component_class.instance_methods.include?(method_name.to_sym)
56
+
57
+ component_class.class_eval <<-RUBY, template[:path], -1
58
+ def #{method_name}
59
+ @output_buffer = ActionView::OutputBuffer.new
60
+ #{compiled_template(template[:path])}
61
+ end
62
+ RUBY
63
+ end
64
+
65
+ define_render_template_for
66
+
67
+ CompileCache.register(component_class)
68
+ end
69
+
70
+ private
71
+
72
+ attr_reader :component_class
73
+
74
+ def define_render_template_for
75
+ component_class.send(:undef_method, :render_template_for) if component_class.instance_methods.include?(:render_template_for)
76
+
77
+ variant_elsifs = variants.compact.uniq.map do |variant|
78
+ "elsif variant.to_sym == :#{variant}\n #{call_method_name(variant)}"
79
+ end.join("\n")
80
+
81
+ component_class.class_eval <<-RUBY
82
+ def render_template_for(variant = nil)
83
+ if variant.nil?
84
+ call
85
+ #{variant_elsifs}
86
+ else
87
+ call
88
+ end
89
+ end
90
+ RUBY
91
+
92
+ end
93
+
94
+ def template_errors
95
+ @_template_errors ||= begin
96
+ errors = []
97
+
98
+ if (templates + inline_calls).empty?
99
+ errors << "Could not find a template file or inline render method for #{component_class}."
100
+ end
101
+
102
+ if templates.count { |template| template[:variant].nil? } > 1
103
+ errors << "More than one template found for #{component_class}. There can only be one default template file per component."
104
+ end
105
+
106
+ invalid_variants = templates
107
+ .group_by { |template| template[:variant] }
108
+ .map { |variant, grouped| variant if grouped.length > 1 }
109
+ .compact
110
+ .sort
111
+
112
+ unless invalid_variants.empty?
113
+ errors << "More than one template found for #{'variant'.pluralize(invalid_variants.count)} #{invalid_variants.map { |v| "'#{v}'" }.to_sentence} in #{component_class}. There can only be one template file per variant."
114
+ end
115
+
116
+ if templates.find { |template| template[:variant].nil? } && inline_calls_defined_on_self.include?(:call)
117
+ errors << "Template file and inline render method found for #{component_class}. There can only be a template file or inline render method per component."
118
+ end
119
+
120
+ duplicate_template_file_and_inline_variant_calls =
121
+ templates.pluck(:variant) & variants_from_inline_calls(inline_calls_defined_on_self)
122
+
123
+ unless duplicate_template_file_and_inline_variant_calls.empty?
124
+ count = duplicate_template_file_and_inline_variant_calls.count
125
+
126
+ 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 #{component_class}. There can only be a template file or inline render method per variant."
127
+ end
128
+
129
+ errors
130
+ end
131
+ end
132
+
133
+ def templates
134
+ @templates ||= matching_views_in_source_location.each_with_object([]) do |path, memo|
135
+ pieces = File.basename(path).split(".")
136
+
137
+ memo << {
138
+ path: path,
139
+ variant: pieces.second.split("+").second&.to_sym,
140
+ handler: pieces.last
141
+ }
142
+ end
143
+ end
144
+
145
+ def matching_views_in_source_location
146
+ source_location = component_class.source_location
147
+ return [] unless source_location
148
+
149
+ extensions = ActionView::Template.template_handler_extensions.join(",")
150
+
151
+ # view files in a directory named like the component
152
+ directory = File.dirname(source_location)
153
+ filename = File.basename(source_location, ".rb")
154
+ component_name = component_class.name.demodulize.underscore
155
+
156
+ # Add support for nested components defined in the same file.
157
+ #
158
+ # e.g.
159
+ #
160
+ # class MyComponent < ViewComponent::Base
161
+ # class MyOtherComponent < ViewComponent::Base
162
+ # end
163
+ # end
164
+ #
165
+ # Without this, `MyOtherComponent` will not look for `my_component/my_other_component.html.erb`
166
+ nested_component_files = if component_class.name.include?("::")
167
+ nested_component_path = component_class.name.deconstantize.underscore
168
+ Dir["#{directory}/#{nested_component_path}/#{component_name}.*{#{extensions}}"]
169
+ else
170
+ []
171
+ end
172
+
173
+ # view files in the same directory as the component
174
+ sidecar_files = Dir["#{directory}/#{component_name}.*{#{extensions}}"]
175
+
176
+ sidecar_directory_files = Dir["#{directory}/#{component_name}/#{filename}.*{#{extensions}}"]
177
+
178
+ (sidecar_files - [source_location] + sidecar_directory_files + nested_component_files)
179
+ end
180
+
181
+ def inline_calls
182
+ @inline_calls ||= begin
183
+ # Fetch only ViewComponent ancestor classes to limit the scope of
184
+ # finding inline calls
185
+ view_component_ancestors =
186
+ component_class.ancestors.take_while { |ancestor| ancestor != ViewComponent::Base } - component_class.included_modules
187
+
188
+ view_component_ancestors.flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call/) }.uniq
189
+ end
190
+ end
191
+
192
+ def inline_calls_defined_on_self
193
+ @inline_calls_defined_on_self ||= component_class.instance_methods(false).grep(/^call/)
194
+ end
195
+
196
+ def variants
197
+ @_variants = (
198
+ templates.map { |template| template[:variant] } + variants_from_inline_calls(inline_calls)
199
+ ).compact.uniq
200
+ end
201
+
202
+ def variants_from_inline_calls(calls)
203
+ calls.reject { |call| call == :call }.map do |variant_call|
204
+ variant_call.to_s.sub("call_", "").to_sym
205
+ end
206
+ end
207
+
208
+ # :nocov:
209
+ def compiled_template(file_path)
210
+ handler = ActionView::Template.handler_for_extension(File.extname(file_path).gsub(".", ""))
211
+ template = File.read(file_path)
212
+
213
+ if handler.method(:call).parameters.length > 1
214
+ handler.call(component_class, template)
215
+ else
216
+ handler.call(OpenStruct.new(source: template, identifier: component_class.identifier, type: component_class.type))
217
+ end
218
+ end
219
+ # :nocov:
220
+
221
+ def call_method_name(variant)
222
+ if variant.present? && variants.include?(variant)
223
+ "call_#{variant}"
224
+ else
225
+ "call"
226
+ end
227
+ end
228
+ end
229
+ end