view_component 2.19.0 → 2.22.1
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 +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +4 -1100
- data/lib/rails/generators/test_unit/templates/component_test.rb.tt +1 -1
- data/lib/view_component.rb +1 -0
- data/lib/view_component/base.rb +16 -186
- data/lib/view_component/compiler.rb +214 -0
- data/lib/view_component/engine.rb +10 -5
- data/lib/view_component/preview.rb +4 -1
- data/lib/view_component/previewable.rb +10 -0
- data/lib/view_component/version.rb +2 -2
- metadata +7 -6
@@ -1,7 +1,7 @@
|
|
1
1
|
require "test_helper"
|
2
2
|
|
3
3
|
class <%= class_name %>ComponentTest < ViewComponent::TestCase
|
4
|
-
|
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
|
data/lib/view_component.rb
CHANGED
data/lib/view_component/base.rb
CHANGED
@@ -64,7 +64,7 @@ module ViewComponent
|
|
64
64
|
@virtual_path ||= virtual_path
|
65
65
|
|
66
66
|
# For template variants (+phone, +desktop, etc.)
|
67
|
-
@variant
|
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
|
-
|
80
|
+
render_template_for(@variant)
|
81
81
|
else
|
82
82
|
""
|
83
83
|
end
|
@@ -149,6 +149,12 @@ module ViewComponent
|
|
149
149
|
nil
|
150
150
|
end
|
151
151
|
|
152
|
+
def with_variant(variant)
|
153
|
+
@variant = variant
|
154
|
+
|
155
|
+
self
|
156
|
+
end
|
157
|
+
|
152
158
|
private
|
153
159
|
|
154
160
|
# Exposes the current request to the component.
|
@@ -187,8 +193,9 @@ module ViewComponent
|
|
187
193
|
# `compile` defines
|
188
194
|
compile
|
189
195
|
|
190
|
-
# If
|
191
|
-
|
196
|
+
# If Rails application is loaded, add application url_helpers to the component context
|
197
|
+
# we need to check this to use this gem as a dependency
|
198
|
+
if defined?(Rails) && Rails.application
|
192
199
|
child.include Rails.application.routes.url_helpers unless child < Rails.application.routes.url_helpers
|
193
200
|
end
|
194
201
|
|
@@ -207,16 +214,8 @@ module ViewComponent
|
|
207
214
|
super
|
208
215
|
end
|
209
216
|
|
210
|
-
def call_method_name(variant)
|
211
|
-
if variant.present? && variants.include?(variant)
|
212
|
-
"call_#{variant}"
|
213
|
-
else
|
214
|
-
"call"
|
215
|
-
end
|
216
|
-
end
|
217
|
-
|
218
217
|
def compiled?
|
219
|
-
|
218
|
+
template_compiler.compiled?
|
220
219
|
end
|
221
220
|
|
222
221
|
# Compile templates to instance methods, assuming they haven't been compiled already.
|
@@ -224,75 +223,11 @@ module ViewComponent
|
|
224
223
|
# Do as much work as possible in this step, as doing so reduces the amount
|
225
224
|
# of work done each time a component is rendered.
|
226
225
|
def compile(raise_errors: false)
|
227
|
-
|
228
|
-
|
229
|
-
if template_errors.present?
|
230
|
-
raise ViewComponent::TemplateError.new(template_errors) if raise_errors
|
231
|
-
return false
|
232
|
-
end
|
233
|
-
|
234
|
-
if instance_methods(false).include?(:before_render_check)
|
235
|
-
ActiveSupport::Deprecation.warn(
|
236
|
-
"`before_render_check` will be removed in v3.0.0. Use `before_render` instead."
|
237
|
-
)
|
238
|
-
end
|
239
|
-
|
240
|
-
# Remove any existing singleton methods,
|
241
|
-
# as Ruby warns when redefining a method.
|
242
|
-
remove_possible_singleton_method(:variants)
|
243
|
-
remove_possible_singleton_method(:collection_parameter)
|
244
|
-
remove_possible_singleton_method(:collection_counter_parameter)
|
245
|
-
remove_possible_singleton_method(:counter_argument_present?)
|
246
|
-
|
247
|
-
define_singleton_method(:variants) do
|
248
|
-
templates.map { |template| template[:variant] } + variants_from_inline_calls(inline_calls)
|
249
|
-
end
|
250
|
-
|
251
|
-
define_singleton_method(:collection_parameter) do
|
252
|
-
if provided_collection_parameter
|
253
|
-
provided_collection_parameter
|
254
|
-
else
|
255
|
-
name.demodulize.underscore.chomp("_component").to_sym
|
256
|
-
end
|
257
|
-
end
|
258
|
-
|
259
|
-
define_singleton_method(:collection_counter_parameter) do
|
260
|
-
"#{collection_parameter}_counter".to_sym
|
261
|
-
end
|
262
|
-
|
263
|
-
define_singleton_method(:counter_argument_present?) do
|
264
|
-
instance_method(:initialize).parameters.map(&:second).include?(collection_counter_parameter)
|
265
|
-
end
|
266
|
-
|
267
|
-
validate_collection_parameter! if raise_errors
|
268
|
-
|
269
|
-
# If template name annotations are turned on, a line is dynamically
|
270
|
-
# added with a comment. In this case, we want to return a different
|
271
|
-
# starting line number so errors that are raised will point to the
|
272
|
-
# correct line in the component template.
|
273
|
-
line_number =
|
274
|
-
if ActionView::Base.respond_to?(:annotate_rendered_view_with_filenames) &&
|
275
|
-
ActionView::Base.annotate_rendered_view_with_filenames
|
276
|
-
-2
|
277
|
-
else
|
278
|
-
-1
|
279
|
-
end
|
280
|
-
|
281
|
-
templates.each do |template|
|
282
|
-
# Remove existing compiled template methods,
|
283
|
-
# as Ruby warns when redefining a method.
|
284
|
-
method_name = call_method_name(template[:variant])
|
285
|
-
undef_method(method_name.to_sym) if instance_methods.include?(method_name.to_sym)
|
286
|
-
|
287
|
-
class_eval <<-RUBY, template[:path], line_number
|
288
|
-
def #{method_name}
|
289
|
-
@output_buffer = ActionView::OutputBuffer.new
|
290
|
-
#{compiled_template(template[:path])}
|
291
|
-
end
|
292
|
-
RUBY
|
293
|
-
end
|
226
|
+
template_compiler.compile(raise_errors: raise_errors)
|
227
|
+
end
|
294
228
|
|
295
|
-
|
229
|
+
def template_compiler
|
230
|
+
@_template_compiler ||= Compiler.new(self)
|
296
231
|
end
|
297
232
|
|
298
233
|
# we'll eventually want to update this to support other types
|
@@ -357,111 +292,6 @@ module ViewComponent
|
|
357
292
|
@provided_collection_parameter ||= nil
|
358
293
|
end
|
359
294
|
|
360
|
-
def compiled_template(file_path)
|
361
|
-
handler = ActionView::Template.handler_for_extension(File.extname(file_path).gsub(".", ""))
|
362
|
-
template = File.read(file_path)
|
363
|
-
|
364
|
-
if handler.method(:call).parameters.length > 1
|
365
|
-
handler.call(self, template)
|
366
|
-
else
|
367
|
-
handler.call(OpenStruct.new(source: template, identifier: identifier, type: type))
|
368
|
-
end
|
369
|
-
end
|
370
|
-
|
371
|
-
def inline_calls
|
372
|
-
@inline_calls ||=
|
373
|
-
begin
|
374
|
-
# Fetch only ViewComponent ancestor classes to limit the scope of
|
375
|
-
# finding inline calls
|
376
|
-
view_component_ancestors =
|
377
|
-
ancestors.take_while { |ancestor| ancestor != ViewComponent::Base } - included_modules
|
378
|
-
|
379
|
-
view_component_ancestors.flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call/) }.uniq
|
380
|
-
end
|
381
|
-
end
|
382
|
-
|
383
|
-
def inline_calls_defined_on_self
|
384
|
-
@inline_calls_defined_on_self ||= instance_methods(false).grep(/^call/)
|
385
|
-
end
|
386
|
-
|
387
|
-
def matching_views_in_source_location
|
388
|
-
return [] unless source_location
|
389
|
-
|
390
|
-
location_without_extension = source_location.chomp(File.extname(source_location))
|
391
|
-
|
392
|
-
extensions = ActionView::Template.template_handler_extensions.join(",")
|
393
|
-
|
394
|
-
# view files in the same directory as the component
|
395
|
-
sidecar_files = Dir["#{location_without_extension}.*{#{extensions}}"]
|
396
|
-
|
397
|
-
# view files in a directory named like the component
|
398
|
-
directory = File.dirname(source_location)
|
399
|
-
filename = File.basename(source_location, ".rb")
|
400
|
-
component_name = name.demodulize.underscore
|
401
|
-
|
402
|
-
sidecar_directory_files = Dir["#{directory}/#{component_name}/#{filename}.*{#{extensions}}"]
|
403
|
-
|
404
|
-
(sidecar_files - [source_location] + sidecar_directory_files)
|
405
|
-
end
|
406
|
-
|
407
|
-
def templates
|
408
|
-
@templates ||=
|
409
|
-
matching_views_in_source_location.each_with_object([]) do |path, memo|
|
410
|
-
pieces = File.basename(path).split(".")
|
411
|
-
|
412
|
-
memo << {
|
413
|
-
path: path,
|
414
|
-
variant: pieces.second.split("+").second&.to_sym,
|
415
|
-
handler: pieces.last
|
416
|
-
}
|
417
|
-
end
|
418
|
-
end
|
419
|
-
|
420
|
-
def template_errors
|
421
|
-
@template_errors ||=
|
422
|
-
begin
|
423
|
-
errors = []
|
424
|
-
|
425
|
-
if (templates + inline_calls).empty?
|
426
|
-
errors << "Could not find a template file or inline render method for #{self}."
|
427
|
-
end
|
428
|
-
|
429
|
-
if templates.count { |template| template[:variant].nil? } > 1
|
430
|
-
errors << "More than one template found for #{self}. There can only be one default template file per component."
|
431
|
-
end
|
432
|
-
|
433
|
-
invalid_variants = templates
|
434
|
-
.group_by { |template| template[:variant] }
|
435
|
-
.map { |variant, grouped| variant if grouped.length > 1 }
|
436
|
-
.compact
|
437
|
-
.sort
|
438
|
-
|
439
|
-
unless invalid_variants.empty?
|
440
|
-
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."
|
441
|
-
end
|
442
|
-
|
443
|
-
if templates.find { |template| template[:variant].nil? } && inline_calls_defined_on_self.include?(:call)
|
444
|
-
errors << "Template file and inline render method found for #{self}. There can only be a template file or inline render method per component."
|
445
|
-
end
|
446
|
-
|
447
|
-
duplicate_template_file_and_inline_variant_calls =
|
448
|
-
templates.pluck(:variant) & variants_from_inline_calls(inline_calls_defined_on_self)
|
449
|
-
|
450
|
-
unless duplicate_template_file_and_inline_variant_calls.empty?
|
451
|
-
count = duplicate_template_file_and_inline_variant_calls.count
|
452
|
-
|
453
|
-
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."
|
454
|
-
end
|
455
|
-
|
456
|
-
errors
|
457
|
-
end
|
458
|
-
end
|
459
|
-
|
460
|
-
def variants_from_inline_calls(calls)
|
461
|
-
calls.reject { |call| call == :call }.map do |variant_call|
|
462
|
-
variant_call.to_s.sub("call_", "").to_sym
|
463
|
-
end
|
464
|
-
end
|
465
295
|
end
|
466
296
|
|
467
297
|
ActiveSupport.run_load_hooks(:view_component, self)
|
@@ -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
|