view_component 3.10.0 → 3.23.2

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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/view_component/preview_actions.rb +8 -1
  3. data/app/helpers/preview_helper.rb +1 -1
  4. data/app/views/view_components/_preview_source.html.erb +1 -1
  5. data/docs/CHANGELOG.md +351 -1
  6. data/lib/rails/generators/abstract_generator.rb +9 -1
  7. data/lib/rails/generators/component/component_generator.rb +2 -1
  8. data/lib/rails/generators/component/templates/component.rb.tt +3 -2
  9. data/lib/rails/generators/erb/component_generator.rb +1 -1
  10. data/lib/rails/generators/preview/templates/component_preview.rb.tt +2 -0
  11. data/lib/rails/generators/rspec/component_generator.rb +15 -3
  12. data/lib/rails/generators/rspec/templates/component_spec.rb.tt +1 -1
  13. data/lib/rails/generators/stimulus/component_generator.rb +8 -3
  14. data/lib/rails/generators/stimulus/templates/component_controller.ts.tt +9 -0
  15. data/lib/rails/generators/test_unit/templates/component_test.rb.tt +1 -1
  16. data/lib/view_component/base.rb +55 -59
  17. data/lib/view_component/collection.rb +18 -3
  18. data/lib/view_component/compiler.rb +164 -240
  19. data/lib/view_component/config.rb +39 -2
  20. data/lib/view_component/configurable.rb +17 -0
  21. data/lib/view_component/engine.rb +21 -11
  22. data/lib/view_component/errors.rb +7 -5
  23. data/lib/view_component/instrumentation.rb +1 -1
  24. data/lib/view_component/preview.rb +1 -1
  25. data/lib/view_component/rails/tasks/view_component.rake +8 -2
  26. data/lib/view_component/slot.rb +11 -1
  27. data/lib/view_component/slotable.rb +29 -15
  28. data/lib/view_component/slotable_default.rb +20 -0
  29. data/lib/view_component/template.rb +134 -0
  30. data/lib/view_component/test_helpers.rb +31 -2
  31. data/lib/view_component/use_helpers.rb +32 -10
  32. data/lib/view_component/version.rb +2 -2
  33. metadata +252 -19
  34. data/lib/rails/generators/component/USAGE +0 -13
  35. data/lib/view_component/docs_builder_component.html.erb +0 -22
  36. data/lib/view_component/docs_builder_component.rb +0 -96
  37. data/lib/yard/mattr_accessor_handler.rb +0 -19
@@ -102,7 +102,7 @@ module ViewComponent # :nodoc:
102
102
 
103
103
  def load_previews
104
104
  Array(preview_paths).each do |preview_path|
105
- Dir["#{preview_path}/**/*_preview.rb"].sort.each { |file| require_dependency file }
105
+ Dir["#{preview_path}/**/*preview.rb"].sort.each { |file| require_dependency file }
106
106
  end
107
107
  end
108
108
 
@@ -7,8 +7,14 @@ namespace :view_component do
7
7
  # :nocov:
8
8
  require "rails/code_statistics"
9
9
 
10
- dir = ViewComponent::Base.view_component_path
11
- ::STATS_DIRECTORIES << ["ViewComponents", dir] if File.directory?(Rails.root + dir)
10
+ if Rails.root.join(ViewComponent::Base.view_component_path).directory?
11
+ ::STATS_DIRECTORIES << ["ViewComponents", ViewComponent::Base.view_component_path]
12
+ end
13
+
14
+ if Rails.root.join("test/components").directory?
15
+ ::STATS_DIRECTORIES << ["ViewComponent tests", "test/components"]
16
+ CodeStatistics::TEST_TYPES << "ViewComponent tests"
17
+ end
12
18
  # :nocov:
13
19
  end
14
20
  end
@@ -57,7 +57,17 @@ module ViewComponent
57
57
 
58
58
  if defined?(@__vc_content_block)
59
59
  # render_in is faster than `parent.render`
60
- @__vc_component_instance.render_in(view_context, &@__vc_content_block)
60
+ @__vc_component_instance.render_in(view_context) do |*args|
61
+ return @__vc_content_block.call(*args) if @__vc_content_block&.source_location.nil?
62
+
63
+ block_context = @__vc_content_block.binding.receiver
64
+
65
+ if block_context.class < ActionView::Base
66
+ block_context.capture(*args, &@__vc_content_block)
67
+ else
68
+ @__vc_content_block.call(*args)
69
+ end
70
+ end
61
71
  else
62
72
  @__vc_component_instance.render_in(view_context)
63
73
  end
@@ -89,11 +89,11 @@ module ViewComponent
89
89
  end
90
90
  ruby2_keywords(setter_method_name) if respond_to?(:ruby2_keywords, true)
91
91
 
92
- define_method slot_name do
92
+ self::GeneratedSlotMethods.define_method slot_name do
93
93
  get_slot(slot_name)
94
94
  end
95
95
 
96
- define_method :"#{slot_name}?" do
96
+ self::GeneratedSlotMethods.define_method :"#{slot_name}?" do
97
97
  get_slot(slot_name).present?
98
98
  end
99
99
 
@@ -176,11 +176,11 @@ module ViewComponent
176
176
  end
177
177
  end
178
178
 
179
- define_method slot_name do
179
+ self::GeneratedSlotMethods.define_method slot_name do
180
180
  get_slot(slot_name)
181
181
  end
182
182
 
183
- define_method :"#{slot_name}?" do
183
+ self::GeneratedSlotMethods.define_method :"#{slot_name}?" do
184
184
  get_slot(slot_name).present?
185
185
  end
186
186
 
@@ -199,19 +199,28 @@ module ViewComponent
199
199
  end
200
200
  end
201
201
 
202
- # Clone slot configuration into child class
203
- # see #test_slots_pollution
204
202
  def inherited(child)
203
+ # Clone slot configuration into child class
204
+ # see #test_slots_pollution
205
205
  child.registered_slots = registered_slots.clone
206
+
207
+ # Add a module for slot methods, allowing them to be overriden by the component class
208
+ # see #test_slot_name_can_be_overriden
209
+ unless child.const_defined?(:GeneratedSlotMethods, false)
210
+ generated_slot_methods = Module.new
211
+ child.const_set(:GeneratedSlotMethods, generated_slot_methods)
212
+ child.include generated_slot_methods
213
+ end
214
+
206
215
  super
207
216
  end
208
217
 
209
218
  def register_polymorphic_slot(slot_name, types, collection:)
210
- define_method(slot_name) do
219
+ self::GeneratedSlotMethods.define_method(slot_name) do
211
220
  get_slot(slot_name)
212
221
  end
213
222
 
214
- define_method(:"#{slot_name}?") do
223
+ self::GeneratedSlotMethods.define_method(:"#{slot_name}?") do
215
224
  get_slot(slot_name).present?
216
225
  end
217
226
 
@@ -259,6 +268,15 @@ module ViewComponent
259
268
  }
260
269
  end
261
270
 
271
+ # Called by the compiler, as instance methods are not defined when slots are first registered
272
+ def register_default_slots
273
+ registered_slots.each do |slot_name, config|
274
+ config[:default_method] = instance_methods.find { |method_name| method_name == :"default_#{slot_name}" }
275
+
276
+ registered_slots[slot_name] = config
277
+ end
278
+ end
279
+
262
280
  private
263
281
 
264
282
  def register_slot(slot_name, **kwargs)
@@ -266,10 +284,7 @@ module ViewComponent
266
284
  end
267
285
 
268
286
  def define_slot(slot_name, collection:, callable:)
269
- # Setup basic slot data
270
- slot = {
271
- collection: collection
272
- }
287
+ slot = {collection: collection}
273
288
  return slot unless callable
274
289
 
275
290
  # If callable responds to `render_in`, we set it on the slot as a renderable
@@ -318,7 +333,6 @@ module ViewComponent
318
333
 
319
334
  def raise_if_slot_registered(slot_name)
320
335
  if registered_slots.key?(slot_name)
321
- # TODO remove? This breaks overriding slots when slots are inherited
322
336
  raise RedefinedSlotError.new(name, slot_name)
323
337
  end
324
338
  end
@@ -366,7 +380,7 @@ module ViewComponent
366
380
  # 1. If this is a `content_area` style sub-component, we will render the
367
381
  # block via the `slot`
368
382
  #
369
- # 2. Since we've to pass block content to components when calling
383
+ # 2. Since we have to pass block content to components when calling
370
384
  # `render`, evaluating the block here would require us to call
371
385
  # `view_context.capture` twice, which is slower
372
386
  slot.__vc_content_block = block if block
@@ -417,7 +431,7 @@ module ViewComponent
417
431
  def set_polymorphic_slot(slot_name, poly_type = nil, *args, &block)
418
432
  slot_definition = self.class.registered_slots[slot_name]
419
433
 
420
- if !slot_definition[:collection] && (defined?(@__vc_set_slots) && @__vc_set_slots[slot_name])
434
+ if !slot_definition[:collection] && defined?(@__vc_set_slots) && @__vc_set_slots[slot_name]
421
435
  raise ContentAlreadySetForPolymorphicSlotError.new(slot_name)
422
436
  end
423
437
 
@@ -0,0 +1,20 @@
1
+ module ViewComponent
2
+ module SlotableDefault
3
+ def get_slot(slot_name)
4
+ @__vc_set_slots ||= {}
5
+
6
+ return super unless !@__vc_set_slots[slot_name] && (default_method = registered_slots[slot_name][:default_method])
7
+
8
+ renderable_value = send(default_method)
9
+ slot = Slot.new(self)
10
+
11
+ if renderable_value.respond_to?(:render_in)
12
+ slot.__vc_component_instance = renderable_value
13
+ else
14
+ slot.__vc_content = renderable_value
15
+ end
16
+
17
+ slot
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ class Template
5
+ DataWithSource = Struct.new(:format, :identifier, :short_identifier, :type, keyword_init: true)
6
+ DataNoSource = Struct.new(:source, :identifier, :type, keyword_init: true)
7
+
8
+ attr_reader :variant, :this_format, :type
9
+
10
+ def initialize(
11
+ component:,
12
+ type:,
13
+ this_format: nil,
14
+ variant: nil,
15
+ lineno: nil,
16
+ path: nil,
17
+ extension: nil,
18
+ source: nil,
19
+ method_name: nil,
20
+ defined_on_self: true
21
+ )
22
+ @component = component
23
+ @type = type
24
+ @this_format = this_format
25
+ @variant = variant&.to_sym
26
+ @lineno = lineno
27
+ @path = path
28
+ @extension = extension
29
+ @source = source
30
+ @method_name = method_name
31
+ @defined_on_self = defined_on_self
32
+
33
+ @source_originally_nil = @source.nil?
34
+
35
+ @call_method_name =
36
+ if @method_name
37
+ @method_name
38
+ else
39
+ out = +"call"
40
+ out << "_#{normalized_variant_name}" if @variant.present?
41
+ out << "_#{@this_format}" if @this_format.present? && @this_format != ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT
42
+ out
43
+ end
44
+ end
45
+
46
+ def compile_to_component
47
+ if !inline_call?
48
+ @component.silence_redefinition_of_method(@call_method_name)
49
+
50
+ # rubocop:disable Style/EvalWithLocation
51
+ @component.class_eval <<-RUBY, @path, @lineno
52
+ def #{@call_method_name}
53
+ #{compiled_source}
54
+ end
55
+ RUBY
56
+ # rubocop:enable Style/EvalWithLocation
57
+ end
58
+
59
+ @component.define_method(safe_method_name, @component.instance_method(@call_method_name))
60
+ end
61
+
62
+ def safe_method_name_call
63
+ return safe_method_name unless inline_call?
64
+
65
+ "maybe_escape_html(#{safe_method_name}) " \
66
+ "{ Kernel.warn(\"WARNING: The #{@component} component rendered HTML-unsafe output. " \
67
+ "The output will be automatically escaped, but you may want to investigate.\") } "
68
+ end
69
+
70
+ def requires_compiled_superclass?
71
+ inline_call? && !defined_on_self?
72
+ end
73
+
74
+ def inline_call?
75
+ @type == :inline_call
76
+ end
77
+
78
+ def inline?
79
+ @type == :inline
80
+ end
81
+
82
+ def default_format?
83
+ @this_format == ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT
84
+ end
85
+
86
+ def format
87
+ @this_format
88
+ end
89
+
90
+ def safe_method_name
91
+ "_#{@call_method_name}_#{@component.name.underscore.gsub("/", "__")}"
92
+ end
93
+
94
+ def normalized_variant_name
95
+ @variant.to_s.gsub("-", "__").gsub(".", "___")
96
+ end
97
+
98
+ def defined_on_self?
99
+ @defined_on_self
100
+ end
101
+
102
+ private
103
+
104
+ def source
105
+ if @source_originally_nil
106
+ # Load file each time we look up #source in case the file has been modified
107
+ File.read(@path)
108
+ else
109
+ @source
110
+ end
111
+ end
112
+
113
+ def compiled_source
114
+ handler = ActionView::Template.handler_for_extension(@extension)
115
+ this_source = source
116
+ this_source.rstrip! if @component.strip_trailing_whitespace?
117
+
118
+ short_identifier = defined?(Rails.root) ? @path.sub("#{Rails.root}/", "") : @path
119
+ type = ActionView::Template::Types[@this_format]
120
+
121
+ if handler.method(:call).parameters.length > 1
122
+ handler.call(
123
+ DataWithSource.new(format: @this_format, identifier: @path, short_identifier: short_identifier, type: type),
124
+ this_source
125
+ )
126
+ # :nocov:
127
+ # TODO: Remove in v4
128
+ else
129
+ handler.call(DataNoSource.new(source: this_source, identifier: @path, type: type))
130
+ end
131
+ # :nocov:
132
+ end
133
+ end
134
+ end
@@ -14,6 +14,10 @@ module ViewComponent
14
14
  def refute_component_rendered
15
15
  assert_no_selector("body")
16
16
  end
17
+
18
+ def assert_component_rendered
19
+ assert_selector("body")
20
+ end
17
21
  rescue LoadError
18
22
  # We don't have a test case for running an application without capybara installed.
19
23
  # It's probably fine to leave this without coverage.
@@ -59,6 +63,16 @@ module ViewComponent
59
63
  Nokogiri::HTML.fragment(@rendered_content)
60
64
  end
61
65
 
66
+ # `JSON.parse`-d component output.
67
+ #
68
+ # ```ruby
69
+ # render_inline(MyJsonComponent.new)
70
+ # assert_equal(rendered_json["hello"], "world")
71
+ # ```
72
+ def rendered_json
73
+ JSON.parse(rendered_content)
74
+ end
75
+
62
76
  # Render a preview inline. Internally sets `page` to be a `Capybara::Node::Simple`,
63
77
  # allowing for Capybara assertions to be used:
64
78
  #
@@ -141,7 +155,7 @@ module ViewComponent
141
155
  # end
142
156
  # ```
143
157
  #
144
- # @param klass [ActionController::Base] The controller to be used.
158
+ # @param klass [Class<ActionController::Base>] The controller to be used.
145
159
  def with_controller_class(klass)
146
160
  old_controller = defined?(@vc_test_controller) && @vc_test_controller
147
161
 
@@ -151,6 +165,19 @@ module ViewComponent
151
165
  @vc_test_controller = old_controller
152
166
  end
153
167
 
168
+ # Set format of the current request
169
+ #
170
+ # ```ruby
171
+ # with_format(:json) do
172
+ # render_inline(MyComponent.new)
173
+ # end
174
+ # ```
175
+ #
176
+ # @param format [Symbol] The format to be set for the provided block.
177
+ def with_format(format)
178
+ with_request_url("/", format: format) { yield }
179
+ end
180
+
154
181
  # Set the URL of the current request (such as when using request-dependent path helpers):
155
182
  #
156
183
  # ```ruby
@@ -178,7 +205,7 @@ module ViewComponent
178
205
  # @param full_path [String] The path to set for the current request.
179
206
  # @param host [String] The host to set for the current request.
180
207
  # @param method [String] The request method to set for the current request.
181
- def with_request_url(full_path, host: nil, method: nil, format: :html)
208
+ def with_request_url(full_path, host: nil, method: nil, format: ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT)
182
209
  old_request_host = vc_test_request.host
183
210
  old_request_method = vc_test_request.request_method
184
211
  old_request_path_info = vc_test_request.path_info
@@ -238,6 +265,8 @@ module ViewComponent
238
265
  #
239
266
  # @return [ActionDispatch::TestRequest]
240
267
  def vc_test_request
268
+ require "action_controller/test_case"
269
+
241
270
  @vc_test_request ||=
242
271
  begin
243
272
  out = ActionDispatch::TestRequest.create
@@ -4,17 +4,39 @@ module ViewComponent::UseHelpers
4
4
  extend ActiveSupport::Concern
5
5
 
6
6
  class_methods do
7
- def use_helpers(*args)
8
- args.each do |helper_method|
9
- class_eval(<<-RUBY, __FILE__, __LINE__ + 1)
10
- def #{helper_method}(*args, &block)
11
- raise HelpersCalledBeforeRenderError if view_context.nil?
12
- __vc_original_view_context.#{helper_method}(*args, &block)
13
- end
14
- RUBY
15
-
16
- ruby2_keywords(helper_method) if respond_to?(:ruby2_keywords, true)
7
+ def use_helpers(*args, from: nil, prefix: false)
8
+ args.each { |helper_method| use_helper(helper_method, from: from, prefix: prefix) }
9
+ end
10
+
11
+ def use_helper(helper_method, from: nil, prefix: false)
12
+ helper_method_name = full_helper_method_name(helper_method, prefix: prefix, source: from)
13
+
14
+ class_eval(<<-RUBY, __FILE__, __LINE__ + 1)
15
+ def #{helper_method_name}(*args, &block)
16
+ raise HelpersCalledBeforeRenderError if view_context.nil?
17
+
18
+ #{define_helper(helper_method: helper_method, source: from)}
19
+ end
20
+ RUBY
21
+ ruby2_keywords(helper_method_name) if respond_to?(:ruby2_keywords, true)
22
+ end
23
+
24
+ private
25
+
26
+ def full_helper_method_name(helper_method, prefix: false, source: nil)
27
+ return helper_method unless prefix.present?
28
+
29
+ if !!prefix == prefix
30
+ "#{source.to_s.underscore}_#{helper_method}"
31
+ else
32
+ "#{prefix}_#{helper_method}"
17
33
  end
18
34
  end
35
+
36
+ def define_helper(helper_method:, source:)
37
+ return "__vc_original_view_context.#{helper_method}(*args, &block)" unless source.present?
38
+
39
+ "#{source}.instance_method(:#{helper_method}).bind(self).call(*args, &block)"
40
+ end
19
41
  end
20
42
  end
@@ -3,8 +3,8 @@
3
3
  module ViewComponent
4
4
  module VERSION
5
5
  MAJOR = 3
6
- MINOR = 10
7
- PATCH = 0
6
+ MINOR = 23
7
+ PATCH = 2
8
8
  PRE = nil
9
9
 
10
10
  STRING = [MAJOR, MINOR, PATCH, PRE].compact.join(".")