view_component 2.18.2 → 2.22.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,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
@@ -64,7 +64,7 @@ module ViewComponent
64
64
  @virtual_path ||= virtual_path
65
65
 
66
66
  # For template variants (+phone, +desktop, etc.)
67
- @variant = @lookup_context.variants.first
67
+ @variant ||= @lookup_context.variants.first
68
68
 
69
69
  # For caching, such as #cache_if
70
70
  @current_template = nil unless defined?(@current_template)
@@ -77,7 +77,7 @@ module ViewComponent
77
77
  before_render
78
78
 
79
79
  if render?
80
- send(self.class.call_method_name(@variant))
80
+ render_template_for(@variant)
81
81
  else
82
82
  ""
83
83
  end
@@ -102,11 +102,7 @@ module ViewComponent
102
102
  # If trying to render a partial or template inside a component,
103
103
  # pass the render call to the parent 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
108
- super
109
- end
105
+ view_context.render(options, args, &block)
110
106
  end
111
107
 
112
108
  def controller
@@ -117,7 +113,7 @@ module ViewComponent
117
113
  # Provides a proxy to access helper methods from the context of the current controller
118
114
  def helpers
119
115
  raise ViewContextCalledBeforeRenderError, "`helpers` can only be called at render time." if view_context.nil?
120
- @helpers ||= controller.view_context
116
+ @helpers ||= view_context
121
117
  end
122
118
 
123
119
  # Exposes .virutal_path as an instance method
@@ -149,6 +145,12 @@ module ViewComponent
149
145
  nil
150
146
  end
151
147
 
148
+ def with_variant(variant)
149
+ @variant = variant
150
+
151
+ self
152
+ end
153
+
152
154
  private
153
155
 
154
156
  # Exposes the current request to the component.
@@ -183,8 +185,13 @@ module ViewComponent
183
185
  end
184
186
 
185
187
  def inherited(child)
186
- # If we're in Rails, add application url_helpers to the component context
187
- if defined?(Rails)
188
+ # Compile so child will inherit compiled `call_*` template methods that
189
+ # `compile` defines
190
+ compile
191
+
192
+ # If Rails application is loaded, add application url_helpers to the component context
193
+ # we need to check this to use this gem as a dependency
194
+ if defined?(Rails) && Rails.application
188
195
  child.include Rails.application.routes.url_helpers unless child < Rails.application.routes.url_helpers
189
196
  end
190
197
 
@@ -203,16 +210,8 @@ module ViewComponent
203
210
  super
204
211
  end
205
212
 
206
- def call_method_name(variant)
207
- if variant.present? && variants.include?(variant)
208
- "call_#{variant}"
209
- else
210
- "call"
211
- end
212
- end
213
-
214
213
  def compiled?
215
- CompileCache.compiled?(self)
214
+ template_compiler.compiled?
216
215
  end
217
216
 
218
217
  # Compile templates to instance methods, assuming they haven't been compiled already.
@@ -220,75 +219,11 @@ module ViewComponent
220
219
  # Do as much work as possible in this step, as doing so reduces the amount
221
220
  # of work done each time a component is rendered.
222
221
  def compile(raise_errors: false)
223
- return if compiled?
224
-
225
- if template_errors.present?
226
- raise ViewComponent::TemplateError.new(template_errors) if raise_errors
227
- return false
228
- end
229
-
230
- if instance_methods(false).include?(:before_render_check)
231
- ActiveSupport::Deprecation.warn(
232
- "`before_render_check` will be removed in v3.0.0. Use `before_render` instead."
233
- )
234
- end
235
-
236
- # Remove any existing singleton methods,
237
- # as Ruby warns when redefining a method.
238
- remove_possible_singleton_method(:variants)
239
- remove_possible_singleton_method(:collection_parameter)
240
- remove_possible_singleton_method(:collection_counter_parameter)
241
- remove_possible_singleton_method(:counter_argument_present?)
242
-
243
- define_singleton_method(:variants) do
244
- templates.map { |template| template[:variant] } + variants_from_inline_calls(inline_calls)
245
- end
246
-
247
- define_singleton_method(:collection_parameter) do
248
- if provided_collection_parameter
249
- provided_collection_parameter
250
- else
251
- name.demodulize.underscore.chomp("_component").to_sym
252
- end
253
- end
254
-
255
- define_singleton_method(:collection_counter_parameter) do
256
- "#{collection_parameter}_counter".to_sym
257
- end
258
-
259
- define_singleton_method(:counter_argument_present?) do
260
- instance_method(:initialize).parameters.map(&:second).include?(collection_counter_parameter)
261
- end
262
-
263
- validate_collection_parameter! if raise_errors
264
-
265
- # If template name annotations are turned on, a line is dynamically
266
- # added with a comment. In this case, we want to return a different
267
- # starting line number so errors that are raised will point to the
268
- # correct line in the component template.
269
- line_number =
270
- if ActionView::Base.respond_to?(:annotate_rendered_view_with_filenames) &&
271
- ActionView::Base.annotate_rendered_view_with_filenames
272
- -2
273
- else
274
- -1
275
- end
276
-
277
- templates.each do |template|
278
- # Remove existing compiled template methods,
279
- # as Ruby warns when redefining a method.
280
- method_name = call_method_name(template[:variant])
281
- undef_method(method_name.to_sym) if instance_methods.include?(method_name.to_sym)
282
-
283
- class_eval <<-RUBY, template[:path], line_number
284
- def #{method_name}
285
- @output_buffer = ActionView::OutputBuffer.new
286
- #{compiled_template(template[:path])}
287
- end
288
- RUBY
289
- end
222
+ template_compiler.compile(raise_errors: raise_errors)
223
+ end
290
224
 
291
- CompileCache.register self
225
+ def template_compiler
226
+ @_template_compiler ||= Compiler.new(self)
292
227
  end
293
228
 
294
229
  # we'll eventually want to update this to support other types
@@ -353,111 +288,6 @@ module ViewComponent
353
288
  @provided_collection_parameter ||= nil
354
289
  end
355
290
 
356
- def compiled_template(file_path)
357
- handler = ActionView::Template.handler_for_extension(File.extname(file_path).gsub(".", ""))
358
- template = File.read(file_path)
359
-
360
- if handler.method(:call).parameters.length > 1
361
- handler.call(self, template)
362
- else
363
- handler.call(OpenStruct.new(source: template, identifier: identifier, type: type))
364
- end
365
- end
366
-
367
- def inline_calls
368
- @inline_calls ||=
369
- begin
370
- # Fetch only ViewComponent ancestor classes to limit the scope of
371
- # finding inline calls
372
- view_component_ancestors =
373
- ancestors.take_while { |ancestor| ancestor != ViewComponent::Base } - included_modules
374
-
375
- view_component_ancestors.flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call/) }.uniq
376
- end
377
- end
378
-
379
- def inline_calls_defined_on_self
380
- @inline_calls_defined_on_self ||= instance_methods(false).grep(/^call/)
381
- end
382
-
383
- def matching_views_in_source_location
384
- return [] unless source_location
385
-
386
- location_without_extension = source_location.chomp(File.extname(source_location))
387
-
388
- extensions = ActionView::Template.template_handler_extensions.join(",")
389
-
390
- # view files in the same directory as the component
391
- sidecar_files = Dir["#{location_without_extension}.*{#{extensions}}"]
392
-
393
- # view files in a directory named like the component
394
- directory = File.dirname(source_location)
395
- filename = File.basename(source_location, ".rb")
396
- component_name = name.demodulize.underscore
397
-
398
- sidecar_directory_files = Dir["#{directory}/#{component_name}/#{filename}.*{#{extensions}}"]
399
-
400
- (sidecar_files - [source_location] + sidecar_directory_files)
401
- end
402
-
403
- def templates
404
- @templates ||=
405
- matching_views_in_source_location.each_with_object([]) do |path, memo|
406
- pieces = File.basename(path).split(".")
407
-
408
- memo << {
409
- path: path,
410
- variant: pieces.second.split("+").second&.to_sym,
411
- handler: pieces.last
412
- }
413
- end
414
- end
415
-
416
- def template_errors
417
- @template_errors ||=
418
- begin
419
- errors = []
420
-
421
- if (templates + inline_calls).empty?
422
- errors << "Could not find a template file or inline render method for #{self}."
423
- end
424
-
425
- if templates.count { |template| template[:variant].nil? } > 1
426
- errors << "More than one template found for #{self}. There can only be one default template file per component."
427
- end
428
-
429
- invalid_variants = templates
430
- .group_by { |template| template[:variant] }
431
- .map { |variant, grouped| variant if grouped.length > 1 }
432
- .compact
433
- .sort
434
-
435
- unless invalid_variants.empty?
436
- 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."
437
- end
438
-
439
- if templates.find { |template| template[:variant].nil? } && inline_calls_defined_on_self.include?(:call)
440
- errors << "Template file and inline render method found for #{self}. There can only be a template file or inline render method per component."
441
- end
442
-
443
- duplicate_template_file_and_inline_variant_calls =
444
- templates.pluck(:variant) & variants_from_inline_calls(inline_calls_defined_on_self)
445
-
446
- unless duplicate_template_file_and_inline_variant_calls.empty?
447
- count = duplicate_template_file_and_inline_variant_calls.count
448
-
449
- 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."
450
- end
451
-
452
- errors
453
- end
454
- end
455
-
456
- def variants_from_inline_calls(calls)
457
- calls.reject { |call| call == :call }.map do |variant_call|
458
- variant_call.to_s.sub("call_", "").to_sym
459
- end
460
- end
461
291
  end
462
292
 
463
293
  ActiveSupport.run_load_hooks(:view_component, self)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "action_view/renderer/collection_renderer" if Rails.version.to_f >= 6.1
4
+
3
5
  module ViewComponent
4
6
  class Collection
5
7
  def render_in(view_context, &block)
@@ -0,0 +1,214 @@
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
+ location_without_extension = source_location.chomp(File.extname(source_location))
150
+
151
+ extensions = ActionView::Template.template_handler_extensions.join(",")
152
+
153
+ # view files in the same directory as the component
154
+ sidecar_files = Dir["#{location_without_extension}.*{#{extensions}}"]
155
+
156
+ # view files in a directory named like the component
157
+ directory = File.dirname(source_location)
158
+ filename = File.basename(source_location, ".rb")
159
+ component_name = component_class.name.demodulize.underscore
160
+
161
+ sidecar_directory_files = Dir["#{directory}/#{component_name}/#{filename}.*{#{extensions}}"]
162
+
163
+ (sidecar_files - [source_location] + sidecar_directory_files)
164
+ end
165
+
166
+ def inline_calls
167
+ @inline_calls ||= begin
168
+ # Fetch only ViewComponent ancestor classes to limit the scope of
169
+ # finding inline calls
170
+ view_component_ancestors =
171
+ component_class.ancestors.take_while { |ancestor| ancestor != ViewComponent::Base } - component_class.included_modules
172
+
173
+ view_component_ancestors.flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call/) }.uniq
174
+ end
175
+ end
176
+
177
+ def inline_calls_defined_on_self
178
+ @inline_calls_defined_on_self ||= component_class.instance_methods(false).grep(/^call/)
179
+ end
180
+
181
+ def variants
182
+ @_variants = (
183
+ templates.map { |template| template[:variant] } + variants_from_inline_calls(inline_calls)
184
+ ).compact.uniq
185
+ end
186
+
187
+ def variants_from_inline_calls(calls)
188
+ calls.reject { |call| call == :call }.map do |variant_call|
189
+ variant_call.to_s.sub("call_", "").to_sym
190
+ end
191
+ end
192
+
193
+ # :nocov:
194
+ def compiled_template(file_path)
195
+ handler = ActionView::Template.handler_for_extension(File.extname(file_path).gsub(".", ""))
196
+ template = File.read(file_path)
197
+
198
+ if handler.method(:call).parameters.length > 1
199
+ handler.call(component_class, template)
200
+ else
201
+ handler.call(OpenStruct.new(source: template, identifier: component_class.identifier, type: component_class.type))
202
+ end
203
+ end
204
+ # :nocov:
205
+
206
+ def call_method_name(variant)
207
+ if variant.present? && variants.include?(variant)
208
+ "call_#{variant}"
209
+ else
210
+ "call"
211
+ end
212
+ end
213
+ end
214
+ end