view_component 2.83.0 → 3.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/view_component/preview_actions.rb +5 -1
  3. data/app/controllers/view_components_system_test_controller.rb +24 -1
  4. data/app/helpers/preview_helper.rb +22 -4
  5. data/app/views/view_components/_preview_source.html.erb +2 -2
  6. data/docs/CHANGELOG.md +807 -1
  7. data/lib/rails/generators/abstract_generator.rb +9 -1
  8. data/lib/rails/generators/component/component_generator.rb +2 -1
  9. data/lib/rails/generators/component/templates/component.rb.tt +3 -2
  10. data/lib/rails/generators/erb/component_generator.rb +1 -1
  11. data/lib/rails/generators/locale/component_generator.rb +3 -3
  12. data/lib/rails/generators/preview/templates/component_preview.rb.tt +2 -0
  13. data/lib/rails/generators/rspec/component_generator.rb +15 -3
  14. data/lib/rails/generators/rspec/templates/component_spec.rb.tt +1 -1
  15. data/lib/rails/generators/stimulus/component_generator.rb +8 -3
  16. data/lib/rails/generators/stimulus/templates/component_controller.ts.tt +9 -0
  17. data/lib/rails/generators/test_unit/templates/component_test.rb.tt +1 -1
  18. data/lib/view_component/base.rb +169 -164
  19. data/lib/view_component/capture_compatibility.rb +44 -0
  20. data/lib/view_component/collection.rb +20 -8
  21. data/lib/view_component/compiler.rb +166 -207
  22. data/lib/view_component/config.rb +63 -14
  23. data/lib/view_component/deprecation.rb +1 -1
  24. data/lib/view_component/docs_builder_component.html.erb +5 -1
  25. data/lib/view_component/docs_builder_component.rb +28 -9
  26. data/lib/view_component/engine.rb +58 -28
  27. data/lib/view_component/errors.rb +240 -0
  28. data/lib/view_component/inline_template.rb +55 -0
  29. data/lib/view_component/instrumentation.rb +10 -2
  30. data/lib/view_component/preview.rb +7 -8
  31. data/lib/view_component/rails/tasks/view_component.rake +11 -2
  32. data/lib/view_component/slot.rb +119 -1
  33. data/lib/view_component/slotable.rb +394 -94
  34. data/lib/view_component/slotable_default.rb +20 -0
  35. data/lib/view_component/system_test_helpers.rb +5 -5
  36. data/lib/view_component/template.rb +134 -0
  37. data/lib/view_component/test_helpers.rb +138 -59
  38. data/lib/view_component/translatable.rb +45 -26
  39. data/lib/view_component/use_helpers.rb +42 -0
  40. data/lib/view_component/version.rb +4 -3
  41. data/lib/view_component/with_content_helper.rb +3 -8
  42. data/lib/view_component.rb +3 -12
  43. metadata +277 -38
  44. data/lib/view_component/content_areas.rb +0 -56
  45. data/lib/view_component/polymorphic_slots.rb +0 -103
  46. data/lib/view_component/preview_template_error.rb +0 -6
  47. data/lib/view_component/slot_v2.rb +0 -98
  48. data/lib/view_component/slotable_v2.rb +0 -391
  49. data/lib/view_component/template_error.rb +0 -9
@@ -10,12 +10,12 @@ module ViewComponent
10
10
  # @param layout [String] The (optional) layout to use.
11
11
  # @return [Proc] A block that can be used to visit the path of the inline rendered component.
12
12
  def with_rendered_component_path(fragment, layout: false, &block)
13
- # Add './tmp/view_components/' directory if it doesn't exist to store the rendered component HTML
14
- FileUtils.mkdir_p("./tmp/view_components/") unless Dir.exist?("./tmp/view_components/")
15
-
16
- file = Tempfile.new(["rendered_#{fragment.class.name}", ".html"], "tmp/view_components/")
13
+ file = Tempfile.new(
14
+ ["rendered_#{fragment.class.name}", ".html"],
15
+ ViewComponentsSystemTestController.temp_dir
16
+ )
17
17
  begin
18
- file.write(controller.render_to_string(html: fragment.to_html.html_safe, layout: layout))
18
+ file.write(vc_test_controller.render_to_string(html: fragment.to_html.html_safe, layout: layout))
19
19
  file.rewind
20
20
 
21
21
  block.call("/_system_test_entrypoint?file=#{file.path.split("/").last}")
@@ -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.
@@ -28,17 +32,10 @@ module ViewComponent
28
32
  # :nocov:
29
33
  end
30
34
 
31
- # @private
32
- attr_reader :rendered_content
33
-
34
35
  # Returns the result of a render_inline call.
35
36
  #
36
- # @return [String]
37
- def rendered_component
38
- ViewComponent::Deprecation.deprecation_warning("`rendered_component`", :"`page`")
39
-
40
- rendered_content
41
- end
37
+ # @return [ActionView::OutputBuffer]
38
+ attr_reader :rendered_content
42
39
 
43
40
  # Render a component inline. Internally sets `page` to be a `Capybara::Node::Simple`,
44
41
  # allowing for Capybara assertions to be used:
@@ -54,14 +51,28 @@ module ViewComponent
54
51
  @page = nil
55
52
  @rendered_content =
56
53
  if Rails.version.to_f >= 6.1
57
- controller.view_context.render(component, args, &block)
54
+ vc_test_controller.view_context.render(component, args, &block)
55
+
56
+ # :nocov:
58
57
  else
59
- controller.view_context.render_component(component, &block)
58
+ vc_test_controller.view_context.render_component(component, &block)
60
59
  end
61
60
 
61
+ # :nocov:
62
+
62
63
  Nokogiri::HTML.fragment(@rendered_content)
63
64
  end
64
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
+
65
76
  # Render a preview inline. Internally sets `page` to be a `Capybara::Node::Simple`,
66
77
  # allowing for Capybara assertions to be used:
67
78
  #
@@ -81,8 +92,8 @@ module ViewComponent
81
92
  # @param from [ViewComponent::Preview] The class of the preview to be rendered.
82
93
  # @param params [Hash] Parameters to be passed to the preview.
83
94
  # @return [Nokogiri::HTML]
84
- def render_preview(name, from: preview_class, params: {})
85
- previews_controller = build_controller(Rails.application.config.view_component.preview_controller.constantize)
95
+ def render_preview(name, from: __vc_test_helpers_preview_class, params: {})
96
+ previews_controller = __vc_test_helpers_build_controller(Rails.application.config.view_component.preview_controller.constantize)
86
97
 
87
98
  # From what I can tell, it's not possible to overwrite all request parameters
88
99
  # at once, so we set them individually here.
@@ -104,7 +115,7 @@ module ViewComponent
104
115
  # Capybara assertions to be used. All arguments are forwarded to the block.
105
116
  #
106
117
  # ```ruby
107
- # render_in_view_context(arg1, arg2:) do |arg1, arg2:|
118
+ # render_in_view_context(arg1, arg2: nil) do |arg1, arg2:|
108
119
  # render(MyComponent.new(arg1, arg2))
109
120
  # end
110
121
  #
@@ -112,26 +123,11 @@ module ViewComponent
112
123
  # ```
113
124
  def render_in_view_context(*args, &block)
114
125
  @page = nil
115
- @rendered_content = controller.view_context.instance_exec(*args, &block)
126
+ @rendered_content = vc_test_controller.view_context.instance_exec(*args, &block)
116
127
  Nokogiri::HTML.fragment(@rendered_content)
117
128
  end
118
129
  ruby2_keywords(:render_in_view_context) if respond_to?(:ruby2_keywords, true)
119
130
 
120
- # @private
121
- def controller
122
- @controller ||= build_controller(Base.test_controller.constantize)
123
- end
124
-
125
- # @private
126
- def request
127
- @request ||=
128
- begin
129
- request = ActionDispatch::TestRequest.create
130
- request.session = ActionController::TestSession.new
131
- request
132
- end
133
- end
134
-
135
131
  # Set the Action Pack request variant for the given block:
136
132
  #
137
133
  # ```ruby
@@ -142,12 +138,12 @@ module ViewComponent
142
138
  #
143
139
  # @param variant [Symbol] The variant to be set for the provided block.
144
140
  def with_variant(variant)
145
- old_variants = controller.view_context.lookup_context.variants
141
+ old_variants = vc_test_controller.view_context.lookup_context.variants
146
142
 
147
- controller.view_context.lookup_context.variants = variant
143
+ vc_test_controller.view_context.lookup_context.variants = variant
148
144
  yield
149
145
  ensure
150
- controller.view_context.lookup_context.variants = old_variants
146
+ vc_test_controller.view_context.lookup_context.variants = old_variants
151
147
  end
152
148
 
153
149
  # Set the controller to be used while executing the given block,
@@ -159,14 +155,27 @@ module ViewComponent
159
155
  # end
160
156
  # ```
161
157
  #
162
- # @param klass [ActionController::Base] The controller to be used.
158
+ # @param klass [Class<ActionController::Base>] The controller to be used.
163
159
  def with_controller_class(klass)
164
- old_controller = defined?(@controller) && @controller
160
+ old_controller = defined?(@vc_test_controller) && @vc_test_controller
165
161
 
166
- @controller = build_controller(klass)
162
+ @vc_test_controller = __vc_test_helpers_build_controller(klass)
167
163
  yield
168
164
  ensure
169
- @controller = old_controller
165
+ @vc_test_controller = old_controller
166
+ end
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 }
170
179
  end
171
180
 
172
181
  # Set the URL of the current request (such as when using request-dependent path helpers):
@@ -177,40 +186,109 @@ module ViewComponent
177
186
  # end
178
187
  # ```
179
188
  #
180
- # @param path [String] The path to set for the current request.
181
- def with_request_url(path)
182
- old_request_path_info = request.path_info
183
- old_request_path_parameters = request.path_parameters
184
- old_request_query_parameters = request.query_parameters
185
- old_request_query_string = request.query_string
186
- old_controller = defined?(@controller) && @controller
187
-
188
- path, query = path.split("?", 2)
189
- request.path_info = path
190
- request.path_parameters = Rails.application.routes.recognize_path_with_request(request, path, {})
191
- request.set_header("action_dispatch.request.query_parameters", Rack::Utils.parse_nested_query(query))
192
- request.set_header(Rack::QUERY_STRING, query)
189
+ # To use a specific host, pass the host param:
190
+ #
191
+ # ```ruby
192
+ # with_request_url("/users/42", host: "app.example.com") do
193
+ # render_inline(MyComponent.new)
194
+ # end
195
+ # ```
196
+ #
197
+ # To specify a request method, pass the method param:
198
+ #
199
+ # ```ruby
200
+ # with_request_url("/users/42", method: "POST") do
201
+ # render_inline(MyComponent.new)
202
+ # end
203
+ # ```
204
+ #
205
+ # @param full_path [String] The path to set for the current request.
206
+ # @param host [String] The host to set for the current request.
207
+ # @param method [String] The request method to set for the current request.
208
+ def with_request_url(full_path, host: nil, method: nil, format: ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT)
209
+ old_request_host = vc_test_request.host
210
+ old_request_method = vc_test_request.request_method
211
+ old_request_path_info = vc_test_request.path_info
212
+ old_request_path_parameters = vc_test_request.path_parameters
213
+ old_request_query_parameters = vc_test_request.query_parameters
214
+ old_request_query_string = vc_test_request.query_string
215
+ old_request_format = vc_test_request.format.symbol
216
+ old_controller = defined?(@vc_test_controller) && @vc_test_controller
217
+
218
+ path, query = full_path.split("?", 2)
219
+ vc_test_request.instance_variable_set(:@fullpath, full_path)
220
+ vc_test_request.instance_variable_set(:@original_fullpath, full_path)
221
+ vc_test_request.host = host if host
222
+ vc_test_request.request_method = method if method
223
+ vc_test_request.path_info = path
224
+ vc_test_request.path_parameters = Rails.application.routes.recognize_path_with_request(vc_test_request, path, {})
225
+ vc_test_request.set_header("action_dispatch.request.query_parameters",
226
+ Rack::Utils.parse_nested_query(query).with_indifferent_access)
227
+ vc_test_request.set_header(Rack::QUERY_STRING, query)
228
+ vc_test_request.format = format
193
229
  yield
194
230
  ensure
195
- request.path_info = old_request_path_info
196
- request.path_parameters = old_request_path_parameters
197
- request.set_header("action_dispatch.request.query_parameters", old_request_query_parameters)
198
- request.set_header(Rack::QUERY_STRING, old_request_query_string)
199
- @controller = old_controller
231
+ vc_test_request.host = old_request_host
232
+ vc_test_request.request_method = old_request_method
233
+ vc_test_request.path_info = old_request_path_info
234
+ vc_test_request.path_parameters = old_request_path_parameters
235
+ vc_test_request.set_header("action_dispatch.request.query_parameters", old_request_query_parameters)
236
+ vc_test_request.set_header(Rack::QUERY_STRING, old_request_query_string)
237
+ vc_test_request.format = old_request_format
238
+ @vc_test_controller = old_controller
200
239
  end
201
240
 
202
- # @private
203
- def build_controller(klass)
204
- klass.new.tap { |c| c.request = request }.extend(Rails.application.routes.url_helpers)
241
+ # Access the controller used by `render_inline`:
242
+ #
243
+ # ```ruby
244
+ # test "logged out user sees login link" do
245
+ # vc_test_controller.expects(:logged_in?).at_least_once.returns(false)
246
+ # render_inline(LoginComponent.new)
247
+ # assert_selector("[aria-label='You must be signed in']")
248
+ # end
249
+ # ```
250
+ #
251
+ # @return [ActionController::Base]
252
+ def vc_test_controller
253
+ @vc_test_controller ||= __vc_test_helpers_build_controller(Base.test_controller.constantize)
254
+ end
255
+
256
+ # Access the request used by `render_inline`:
257
+ #
258
+ # ```ruby
259
+ # test "component does not render in Firefox" do
260
+ # request.env["HTTP_USER_AGENT"] = "Mozilla/5.0"
261
+ # render_inline(NoFirefoxComponent.new)
262
+ # refute_component_rendered
263
+ # end
264
+ # ```
265
+ #
266
+ # @return [ActionDispatch::TestRequest]
267
+ def vc_test_request
268
+ require "action_controller/test_case"
269
+
270
+ @vc_test_request ||=
271
+ begin
272
+ out = ActionDispatch::TestRequest.create
273
+ out.session = ActionController::TestSession.new
274
+ out
275
+ end
205
276
  end
206
277
 
278
+ # Note: We prefix private methods here to prevent collisions in consumer's tests.
207
279
  private
208
280
 
209
- def preview_class
281
+ def __vc_test_helpers_build_controller(klass)
282
+ klass.new.tap { |c| c.request = vc_test_request }.extend(Rails.application.routes.url_helpers)
283
+ end
284
+
285
+ def __vc_test_helpers_preview_class
210
286
  result = if respond_to?(:described_class)
287
+ # :nocov:
211
288
  raise "`render_preview` expected a described_class, but it is nil." if described_class.nil?
212
289
 
213
290
  "#{described_class}Preview"
291
+ # :nocov:
214
292
  else
215
293
  self.class.name.gsub("Test", "Preview")
216
294
  end
@@ -218,5 +296,6 @@ module ViewComponent
218
296
  rescue NameError
219
297
  raise NameError, "`render_preview` expected to find #{result}, but it does not exist."
220
298
  end
299
+ # :nocov:
221
300
  end
222
301
  end
@@ -10,6 +10,7 @@ module ViewComponent
10
10
  extend ActiveSupport::Concern
11
11
 
12
12
  HTML_SAFE_TRANSLATION_KEY = /(?:_|\b)html\z/
13
+ TRANSLATION_EXTENSIONS = %w[yml yaml].freeze
13
14
 
14
15
  included do
15
16
  class_attribute :i18n_backend, instance_writer: false, instance_predicate: false
@@ -21,17 +22,45 @@ module ViewComponent
21
22
  end
22
23
 
23
24
  def build_i18n_backend
24
- return if CompileCache.compiled? self
25
+ return if compiled?
25
26
 
26
- self.i18n_backend = if (translation_files = sidecar_files(%w[yml yaml])).any?
27
- # Returning nil cleans up if translations file has been removed since the last compilation
27
+ # We need to load the translations files from the ancestors so a component
28
+ # can inherit translations from its parent and is able to overwrite them.
29
+ translation_files = ancestors.reverse_each.with_object([]) do |ancestor, files|
30
+ if ancestor.is_a?(Class) && ancestor < ViewComponent::Base
31
+ files.concat(ancestor.sidecar_files(TRANSLATION_EXTENSIONS))
32
+ end
33
+ end
28
34
 
35
+ # In development it will become nil if the translations file is removed
36
+ self.i18n_backend = if translation_files.any?
29
37
  I18nBackend.new(
30
38
  i18n_scope: i18n_scope,
31
39
  load_paths: translation_files
32
40
  )
33
41
  end
34
42
  end
43
+
44
+ def i18n_key(key, scope = nil)
45
+ scope = scope.join(".") if scope.is_a? Array
46
+ key = key&.to_s unless key.is_a?(String)
47
+ key = "#{scope}.#{key}" if scope
48
+ key = "#{i18n_scope}#{key}" if key.start_with?(".")
49
+ key
50
+ end
51
+
52
+ def translate(key = nil, **options)
53
+ return key.map { |k| translate(k, **options) } if key.is_a?(Array)
54
+
55
+ ensure_compiled
56
+
57
+ locale = options.delete(:locale) || ::I18n.locale
58
+ key = i18n_key(key, options.delete(:scope))
59
+
60
+ i18n_backend.translate(locale, key, options)
61
+ end
62
+
63
+ alias_method :t, :translate
35
64
  end
36
65
 
37
66
  class I18nBackend < ::I18n::Backend::Simple
@@ -60,19 +89,16 @@ module ViewComponent
60
89
  end
61
90
 
62
91
  def translate(key = nil, **options)
92
+ raise ViewComponent::TranslateCalledBeforeRenderError if view_context.nil?
93
+
63
94
  return super unless i18n_backend
64
95
  return key.map { |k| translate(k, **options) } if key.is_a?(Array)
65
96
 
66
97
  locale = options.delete(:locale) || ::I18n.locale
67
- scope = options.delete(:scope)
68
- scope = scope.join(".") if scope.is_a? Array
69
- key = key&.to_s unless key.is_a?(String)
70
- key = "#{scope}.#{key}" if scope
71
- key = "#{i18n_scope}#{key}" if key.start_with?(".")
72
-
73
- if HTML_SAFE_TRANSLATION_KEY.match?(key)
74
- html_escape_translation_options!(options)
75
- end
98
+ key = self.class.i18n_key(key, options.delete(:scope))
99
+ as_html = HTML_SAFE_TRANSLATION_KEY.match?(key)
100
+
101
+ html_escape_translation_options!(options) if as_html
76
102
 
77
103
  if key.start_with?(i18n_scope + ".")
78
104
  translated =
@@ -85,10 +111,7 @@ module ViewComponent
85
111
  return super(key, locale: locale, **options)
86
112
  end
87
113
 
88
- if HTML_SAFE_TRANSLATION_KEY.match?(key)
89
- translated = html_safe_translation(translated)
90
- end
91
-
114
+ translated = html_safe_translation(translated) if as_html
92
115
  translated
93
116
  else
94
117
  super(key, locale: locale, **options)
@@ -101,6 +124,8 @@ module ViewComponent
101
124
  self.class.i18n_scope
102
125
  end
103
126
 
127
+ private
128
+
104
129
  def html_safe_translation(translation)
105
130
  if translation.respond_to?(:map)
106
131
  translation.map { |element| html_safe_translation(element) }
@@ -112,18 +137,12 @@ module ViewComponent
112
137
  end
113
138
  end
114
139
 
115
- private
116
-
117
140
  def html_escape_translation_options!(options)
118
- options.each do |name, value|
119
- unless i18n_option?(name) || (name == :count && value.is_a?(Numeric))
120
- options[name] = ERB::Util.html_escape(value.to_s)
121
- end
122
- end
123
- end
141
+ options.except(*::I18n::RESERVED_KEYS).each do |name, value|
142
+ next if name == :count && value.is_a?(Numeric)
124
143
 
125
- def i18n_option?(name)
126
- (@i18n_option_names ||= I18n::RESERVED_KEYS.to_set).include?(name)
144
+ options[name] = ERB::Util.html_escape(value.to_s)
145
+ end
127
146
  end
128
147
  end
129
148
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent::UseHelpers
4
+ extend ActiveSupport::Concern
5
+
6
+ class_methods do
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}"
33
+ end
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
41
+ end
42
+ end
@@ -2,11 +2,12 @@
2
2
 
3
3
  module ViewComponent
4
4
  module VERSION
5
- MAJOR = 2
6
- MINOR = 83
5
+ MAJOR = 3
6
+ MINOR = 21
7
7
  PATCH = 0
8
+ PRE = nil
8
9
 
9
- STRING = [MAJOR, MINOR, PATCH].join(".")
10
+ STRING = [MAJOR, MINOR, PATCH, PRE].compact.join(".")
10
11
  end
11
12
  end
12
13
 
@@ -3,14 +3,9 @@
3
3
  module ViewComponent
4
4
  module WithContentHelper
5
5
  def with_content(value)
6
- if value.nil?
7
- raise ArgumentError.new(
8
- "No content provided to `#with_content` for #{self}.\n\n" \
9
- "To fix this issue, pass a value."
10
- )
11
- else
12
- @__vc_content_set_by_with_content = value
13
- end
6
+ raise NilWithContentError if value.nil?
7
+
8
+ @__vc_content_set_by_with_content = value
14
9
 
15
10
  self
16
11
  end
@@ -7,29 +7,20 @@ module ViewComponent
7
7
  extend ActiveSupport::Autoload
8
8
 
9
9
  autoload :Base
10
+ autoload :CaptureCompatibility
10
11
  autoload :Compiler
11
12
  autoload :CompileCache
12
13
  autoload :ComponentError
13
14
  autoload :Config
14
15
  autoload :Deprecation
16
+ autoload :InlineTemplate
15
17
  autoload :Instrumentation
16
18
  autoload :Preview
17
- autoload :PreviewTemplateError
18
19
  autoload :TestHelpers
19
20
  autoload :SystemTestHelpers
20
21
  autoload :TestCase
21
22
  autoload :SystemTestCase
22
- autoload :TemplateError
23
23
  autoload :Translatable
24
24
  end
25
25
 
26
- # :nocov:
27
- if defined?(ViewComponent::Engine)
28
- ViewComponent::Deprecation.deprecation_warning(
29
- "Manually loading the engine",
30
- "remove `require \"view_component/engine\"`"
31
- )
32
- elsif defined?(Rails::Engine)
33
- require "view_component/engine"
34
- end
35
- # :nocov:
26
+ require "view_component/engine" if defined?(Rails::Engine)