view_component 2.49.1 → 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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +1 -1
  3. data/app/assets/vendor/prism.css +3 -195
  4. data/app/assets/vendor/prism.min.js +11 -11
  5. data/app/controllers/concerns/view_component/preview_actions.rb +108 -0
  6. data/app/controllers/view_components_controller.rb +1 -87
  7. data/app/controllers/view_components_system_test_controller.rb +30 -0
  8. data/app/helpers/preview_helper.rb +30 -12
  9. data/app/views/view_components/_preview_source.html.erb +3 -3
  10. data/app/views/view_components/preview.html.erb +2 -2
  11. data/docs/CHANGELOG.md +1653 -24
  12. data/lib/rails/generators/abstract_generator.rb +16 -10
  13. data/lib/rails/generators/component/component_generator.rb +8 -4
  14. data/lib/rails/generators/component/templates/component.rb.tt +3 -2
  15. data/lib/rails/generators/erb/component_generator.rb +1 -1
  16. data/lib/rails/generators/locale/component_generator.rb +4 -4
  17. data/lib/rails/generators/preview/component_generator.rb +17 -3
  18. data/lib/rails/generators/preview/templates/component_preview.rb.tt +5 -1
  19. data/lib/rails/generators/rspec/component_generator.rb +15 -3
  20. data/lib/rails/generators/rspec/templates/component_spec.rb.tt +3 -1
  21. data/lib/rails/generators/stimulus/component_generator.rb +8 -3
  22. data/lib/rails/generators/stimulus/templates/component_controller.ts.tt +9 -0
  23. data/lib/rails/generators/test_unit/templates/component_test.rb.tt +3 -1
  24. data/lib/view_component/base.rb +352 -196
  25. data/lib/view_component/capture_compatibility.rb +44 -0
  26. data/lib/view_component/collection.rb +28 -9
  27. data/lib/view_component/compiler.rb +162 -193
  28. data/lib/view_component/config.rb +225 -0
  29. data/lib/view_component/configurable.rb +17 -0
  30. data/lib/view_component/deprecation.rb +8 -0
  31. data/lib/view_component/engine.rb +74 -47
  32. data/lib/view_component/errors.rb +240 -0
  33. data/lib/view_component/inline_template.rb +55 -0
  34. data/lib/view_component/instrumentation.rb +10 -2
  35. data/lib/view_component/preview.rb +21 -19
  36. data/lib/view_component/rails/tasks/view_component.rake +11 -2
  37. data/lib/view_component/render_component_helper.rb +1 -0
  38. data/lib/view_component/render_component_to_string_helper.rb +1 -1
  39. data/lib/view_component/render_to_string_monkey_patch.rb +1 -1
  40. data/lib/view_component/rendering_component_helper.rb +1 -1
  41. data/lib/view_component/rendering_monkey_patch.rb +1 -1
  42. data/lib/view_component/slot.rb +119 -1
  43. data/lib/view_component/slotable.rb +393 -96
  44. data/lib/view_component/slotable_default.rb +20 -0
  45. data/lib/view_component/system_test_case.rb +13 -0
  46. data/lib/view_component/system_test_helpers.rb +27 -0
  47. data/lib/view_component/template.rb +134 -0
  48. data/lib/view_component/test_helpers.rb +208 -47
  49. data/lib/view_component/translatable.rb +51 -33
  50. data/lib/view_component/use_helpers.rb +42 -0
  51. data/lib/view_component/version.rb +5 -4
  52. data/lib/view_component/with_content_helper.rb +3 -8
  53. data/lib/view_component.rb +7 -12
  54. metadata +339 -57
  55. data/lib/rails/generators/component/USAGE +0 -13
  56. data/lib/view_component/content_areas.rb +0 -57
  57. data/lib/view_component/polymorphic_slots.rb +0 -73
  58. data/lib/view_component/preview_template_error.rb +0 -6
  59. data/lib/view_component/previewable.rb +0 -62
  60. data/lib/view_component/slot_v2.rb +0 -104
  61. data/lib/view_component/slotable_v2.rb +0 -307
  62. data/lib/view_component/template_error.rb +0 -9
  63. data/lib/yard/mattr_accessor_handler.rb +0 -19
@@ -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
@@ -4,31 +4,38 @@ module ViewComponent
4
4
  module TestHelpers
5
5
  begin
6
6
  require "capybara/minitest"
7
+
7
8
  include Capybara::Minitest::Assertions
8
9
 
9
10
  def page
10
- Capybara::Node::Simple.new(@rendered_component)
11
+ @page ||= Capybara::Node::Simple.new(rendered_content)
11
12
  end
12
13
 
13
14
  def refute_component_rendered
14
15
  assert_no_selector("body")
15
16
  end
17
+
18
+ def assert_component_rendered
19
+ assert_selector("body")
20
+ end
16
21
  rescue LoadError
17
22
  # We don't have a test case for running an application without capybara installed.
18
23
  # It's probably fine to leave this without coverage.
19
24
  # :nocov:
20
25
  if ENV["DEBUG"]
21
26
  warn(
22
- "WARNING in `ViewComponent::TestHelpers`: You must add `capybara` " \
23
- "to your Gemfile to use Capybara assertions."
27
+ "WARNING in `ViewComponent::TestHelpers`: Add `capybara` " \
28
+ "to Gemfile to use Capybara assertions."
24
29
  )
25
30
  end
26
31
 
27
32
  # :nocov:
28
33
  end
29
34
 
30
- # @private
31
- attr_reader :rendered_component
35
+ # Returns the result of a render_inline call.
36
+ #
37
+ # @return [ActionView::OutputBuffer]
38
+ attr_reader :rendered_content
32
39
 
33
40
  # Render a component inline. Internally sets `page` to be a `Capybara::Node::Simple`,
34
41
  # allowing for Capybara assertions to be used:
@@ -41,31 +48,86 @@ module ViewComponent
41
48
  # @param component [ViewComponent::Base, ViewComponent::Collection] The instance of the component to be rendered.
42
49
  # @return [Nokogiri::HTML]
43
50
  def render_inline(component, **args, &block)
44
- @rendered_component =
51
+ @page = nil
52
+ @rendered_content =
45
53
  if Rails.version.to_f >= 6.1
46
- controller.view_context.render(component, args, &block)
54
+ vc_test_controller.view_context.render(component, args, &block)
55
+
56
+ # :nocov:
47
57
  else
48
- controller.view_context.render_component(component, &block)
58
+ vc_test_controller.view_context.render_component(component, &block)
49
59
  end
50
60
 
51
- Nokogiri::HTML.fragment(@rendered_component)
61
+ # :nocov:
62
+
63
+ Nokogiri::HTML.fragment(@rendered_content)
52
64
  end
53
65
 
54
- # @private
55
- def controller
56
- @controller ||= build_controller(Base.test_controller.constantize)
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)
57
74
  end
58
75
 
59
- # @private
60
- def request
61
- @request ||=
62
- begin
63
- request = ActionDispatch::TestRequest.create
64
- request.session = ActionController::TestSession.new
65
- request
66
- end
76
+ # Render a preview inline. Internally sets `page` to be a `Capybara::Node::Simple`,
77
+ # allowing for Capybara assertions to be used:
78
+ #
79
+ # ```ruby
80
+ # render_preview(:default)
81
+ # assert_text("Hello, World!")
82
+ # ```
83
+ #
84
+ # Note: `#rendered_preview` expects a preview to be defined with the same class
85
+ # name as the calling test, but with `Test` replaced with `Preview`:
86
+ #
87
+ # MyComponentTest -> MyComponentPreview etc.
88
+ #
89
+ # In RSpec, `Preview` is appended to `described_class`.
90
+ #
91
+ # @param name [String] The name of the preview to be rendered.
92
+ # @param from [ViewComponent::Preview] The class of the preview to be rendered.
93
+ # @param params [Hash] Parameters to be passed to the preview.
94
+ # @return [Nokogiri::HTML]
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)
97
+
98
+ # From what I can tell, it's not possible to overwrite all request parameters
99
+ # at once, so we set them individually here.
100
+ params.each do |k, v|
101
+ previews_controller.request.params[k] = v
102
+ end
103
+
104
+ previews_controller.request.params[:path] = "#{from.preview_name}/#{name}"
105
+ previews_controller.set_response!(ActionDispatch::Response.new)
106
+ result = previews_controller.previews
107
+
108
+ @rendered_content = result
109
+
110
+ Nokogiri::HTML.fragment(@rendered_content)
67
111
  end
68
112
 
113
+ # Execute the given block in the view context (using `instance_exec`).
114
+ # Internally sets `page` to be a `Capybara::Node::Simple`, allowing for
115
+ # Capybara assertions to be used. All arguments are forwarded to the block.
116
+ #
117
+ # ```ruby
118
+ # render_in_view_context(arg1, arg2: nil) do |arg1, arg2:|
119
+ # render(MyComponent.new(arg1, arg2))
120
+ # end
121
+ #
122
+ # assert_text("Hello, World!")
123
+ # ```
124
+ def render_in_view_context(*args, &block)
125
+ @page = nil
126
+ @rendered_content = vc_test_controller.view_context.instance_exec(*args, &block)
127
+ Nokogiri::HTML.fragment(@rendered_content)
128
+ end
129
+ ruby2_keywords(:render_in_view_context) if respond_to?(:ruby2_keywords, true)
130
+
69
131
  # Set the Action Pack request variant for the given block:
70
132
  #
71
133
  # ```ruby
@@ -76,12 +138,12 @@ module ViewComponent
76
138
  #
77
139
  # @param variant [Symbol] The variant to be set for the provided block.
78
140
  def with_variant(variant)
79
- old_variants = controller.view_context.lookup_context.variants
141
+ old_variants = vc_test_controller.view_context.lookup_context.variants
80
142
 
81
- controller.view_context.lookup_context.variants = variant
143
+ vc_test_controller.view_context.lookup_context.variants = variant
82
144
  yield
83
145
  ensure
84
- controller.view_context.lookup_context.variants = old_variants
146
+ vc_test_controller.view_context.lookup_context.variants = old_variants
85
147
  end
86
148
 
87
149
  # Set the controller to be used while executing the given block,
@@ -93,14 +155,27 @@ module ViewComponent
93
155
  # end
94
156
  # ```
95
157
  #
96
- # @param klass [ActionController::Base] The controller to be used.
158
+ # @param klass [Class<ActionController::Base>] The controller to be used.
97
159
  def with_controller_class(klass)
98
- old_controller = defined?(@controller) && @controller
160
+ old_controller = defined?(@vc_test_controller) && @vc_test_controller
99
161
 
100
- @controller = build_controller(klass)
162
+ @vc_test_controller = __vc_test_helpers_build_controller(klass)
101
163
  yield
102
164
  ensure
103
- @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 }
104
179
  end
105
180
 
106
181
  # Set the URL of the current request (such as when using request-dependent path helpers):
@@ -111,30 +186,116 @@ module ViewComponent
111
186
  # end
112
187
  # ```
113
188
  #
114
- # @param path [String] The path to set for the current request.
115
- def with_request_url(path)
116
- old_request_path_info = request.path_info
117
- old_request_path_parameters = request.path_parameters
118
- old_request_query_parameters = request.query_parameters
119
- old_request_query_string = request.query_string
120
- old_controller = defined?(@controller) && @controller
121
-
122
- request.path_info = path
123
- request.path_parameters = Rails.application.routes.recognize_path(path)
124
- request.set_header("action_dispatch.request.query_parameters", Rack::Utils.parse_query(path.split("?")[1]))
125
- request.set_header(Rack::QUERY_STRING, path.split("?")[1])
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
126
229
  yield
127
230
  ensure
128
- request.path_info = old_request_path_info
129
- request.path_parameters = old_request_path_parameters
130
- request.set_header("action_dispatch.request.query_parameters", old_request_query_parameters)
131
- request.set_header(Rack::QUERY_STRING, old_request_query_string)
132
- @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
133
239
  end
134
240
 
135
- # @private
136
- def build_controller(klass)
137
- 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
276
+ end
277
+
278
+ # Note: We prefix private methods here to prevent collisions in consumer's tests.
279
+ private
280
+
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
286
+ result = if respond_to?(:described_class)
287
+ # :nocov:
288
+ raise "`render_preview` expected a described_class, but it is nil." if described_class.nil?
289
+
290
+ "#{described_class}Preview"
291
+ # :nocov:
292
+ else
293
+ self.class.name.gsub("Test", "Preview")
294
+ end
295
+ result = result.constantize
296
+ rescue NameError
297
+ raise NameError, "`render_preview` expected to find #{result}, but it does not exist."
138
298
  end
299
+ # :nocov:
139
300
  end
140
301
  end
@@ -3,14 +3,14 @@
3
3
  require "erb"
4
4
  require "set"
5
5
  require "i18n"
6
- require "action_view/helpers/translation_helper"
7
6
  require "active_support/concern"
8
7
 
9
8
  module ViewComponent
10
9
  module Translatable
11
10
  extend ActiveSupport::Concern
12
11
 
13
- HTML_SAFE_TRANSLATION_KEY = /(?:_|\b)html\z/.freeze
12
+ HTML_SAFE_TRANSLATION_KEY = /(?:_|\b)html\z/
13
+ TRANSLATION_EXTENSIONS = %w[yml yaml].freeze
14
14
 
15
15
  included do
16
16
  class_attribute :i18n_backend, instance_writer: false, instance_predicate: false
@@ -21,21 +21,46 @@ module ViewComponent
21
21
  @i18n_scope ||= virtual_path.sub(%r{^/}, "").gsub(%r{/_?}, ".")
22
22
  end
23
23
 
24
- def _after_compile
25
- super
24
+ def build_i18n_backend
25
+ return if compiled?
26
26
 
27
- return if CompileCache.compiled? self
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
 
29
- if (translation_files = _sidecar_files(%w[yml yaml])).any?
30
- self.i18n_backend = I18nBackend.new(
35
+ # In development it will become nil if the translations file is removed
36
+ self.i18n_backend = if translation_files.any?
37
+ I18nBackend.new(
31
38
  i18n_scope: i18n_scope,
32
- load_paths: translation_files,
39
+ load_paths: translation_files
33
40
  )
34
- else
35
- # Cleanup if translations file has been removed since the last compilation
36
- self.i18n_backend = nil
37
41
  end
38
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
39
64
  end
40
65
 
41
66
  class I18nBackend < ::I18n::Backend::Simple
@@ -53,7 +78,7 @@ module ViewComponent
53
78
 
54
79
  def scope_data(data)
55
80
  @i18n_scope.reverse_each do |part|
56
- data = { part => data }
81
+ data = {part => data}
57
82
  end
58
83
  data
59
84
  end
@@ -64,16 +89,16 @@ module ViewComponent
64
89
  end
65
90
 
66
91
  def translate(key = nil, **options)
92
+ raise ViewComponent::TranslateCalledBeforeRenderError if view_context.nil?
93
+
67
94
  return super unless i18n_backend
68
95
  return key.map { |k| translate(k, **options) } if key.is_a?(Array)
69
96
 
70
97
  locale = options.delete(:locale) || ::I18n.locale
71
- key = key&.to_s unless key.is_a?(String)
72
- key = "#{i18n_scope}#{key}" if key.start_with?(".")
98
+ key = self.class.i18n_key(key, options.delete(:scope))
99
+ as_html = HTML_SAFE_TRANSLATION_KEY.match?(key)
73
100
 
74
- if HTML_SAFE_TRANSLATION_KEY.match?(key)
75
- html_escape_translation_options!(options)
76
- end
101
+ html_escape_translation_options!(options) if as_html
77
102
 
78
103
  if key.start_with?(i18n_scope + ".")
79
104
  translated =
@@ -86,22 +111,21 @@ module ViewComponent
86
111
  return super(key, locale: locale, **options)
87
112
  end
88
113
 
89
- if HTML_SAFE_TRANSLATION_KEY.match?(key)
90
- translated = translated.html_safe # rubocop:disable Rails/OutputSafety
91
- end
92
-
114
+ translated = html_safe_translation(translated) if as_html
93
115
  translated
94
116
  else
95
117
  super(key, locale: locale, **options)
96
118
  end
97
119
  end
98
- alias :t :translate
120
+ alias_method :t, :translate
99
121
 
100
122
  # Exposes .i18n_scope as an instance method
101
123
  def i18n_scope
102
124
  self.class.i18n_scope
103
125
  end
104
126
 
127
+ private
128
+
105
129
  def html_safe_translation(translation)
106
130
  if translation.respond_to?(:map)
107
131
  translation.map { |element| html_safe_translation(element) }
@@ -109,22 +133,16 @@ module ViewComponent
109
133
  # It's assumed here that objects loaded by the i18n backend will respond to `#html_safe?`.
110
134
  # It's reasonable that if we're in Rails, `active_support/core_ext/string/output_safety.rb`
111
135
  # will provide this to `Object`.
112
- translation.html_safe # rubocop:disable Rails/OutputSafety
136
+ translation.html_safe
113
137
  end
114
138
  end
115
139
 
116
- private
117
-
118
140
  def html_escape_translation_options!(options)
119
- options.each do |name, value|
120
- unless i18n_option?(name) || (name == :count && value.is_a?(Numeric))
121
- options[name] = ERB::Util.html_escape(value.to_s)
122
- end
123
- end
124
- end
141
+ options.except(*::I18n::RESERVED_KEYS).each do |name, value|
142
+ next if name == :count && value.is_a?(Numeric)
125
143
 
126
- def i18n_option?(name)
127
- (@i18n_option_names ||= I18n::RESERVED_KEYS.to_set).include?(name)
144
+ options[name] = ERB::Util.html_escape(value.to_s)
145
+ end
128
146
  end
129
147
  end
130
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 = 49
7
- PATCH = 1
5
+ MAJOR = 3
6
+ MINOR = 23
7
+ PATCH = 2
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