view_component 2.18.0 → 2.20.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +129 -77
- data/README.md +161 -40
- data/app/views/view_components/preview.html.erb +5 -1
- 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 +17 -185
- data/lib/view_component/collection.rb +2 -0
- data/lib/view_component/compiler.rb +214 -0
- data/lib/view_component/engine.rb +9 -4
- data/lib/view_component/preview.rb +6 -15
- data/lib/view_component/previewable.rb +10 -0
- data/lib/view_component/test_helpers.rb +4 -0
- data/lib/view_component/version.rb +1 -1
- metadata +20 -5
@@ -1 +1,5 @@
|
|
1
|
-
|
1
|
+
<% if ViewComponent::Base.render_monkey_patch_enabled || Rails.version.to_f >= 6.1 %>
|
2
|
+
<%= render(@render_args[:component], @render_args[:args], &@render_args[:block])%>
|
3
|
+
<% else %>
|
4
|
+
<%= render_component(@render_args[:component], &@render_args[:block])%>
|
5
|
+
<% end %>
|
@@ -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
@@ -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
|
-
|
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
|
-
#
|
183
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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)
|
@@ -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
|