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