view_component 4.20.0 → 4.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/docs/CHANGELOG.md +94 -0
  3. data/lib/generators/view_component/abstract_generator.rb +24 -4
  4. data/lib/generators/view_component/component/component_generator.rb +8 -6
  5. data/lib/generators/view_component/component/templates/component.rb.tt +1 -1
  6. data/lib/generators/view_component/erb/erb_generator.rb +2 -2
  7. data/lib/generators/view_component/haml/haml_generator.rb +1 -1
  8. data/lib/generators/view_component/locale/locale_generator.rb +2 -6
  9. data/lib/generators/view_component/preview/preview_generator.rb +9 -6
  10. data/lib/generators/view_component/preview/templates/component_preview.rb.tt +2 -2
  11. data/lib/generators/view_component/rspec/rspec_generator.rb +1 -1
  12. data/lib/generators/view_component/rspec/templates/component_spec.rb.tt +1 -1
  13. data/lib/generators/view_component/slim/slim_generator.rb +1 -1
  14. data/lib/generators/view_component/stimulus/stimulus_generator.rb +3 -7
  15. data/lib/generators/view_component/test_unit/templates/component_test.rb.tt +2 -2
  16. data/lib/generators/view_component/test_unit/test_unit_generator.rb +8 -7
  17. data/lib/view_component/base.rb +106 -98
  18. data/lib/view_component/cache_digest/dependency_tracking.rb +6 -6
  19. data/lib/view_component/cache_digest/digestor_lookup.rb +3 -1
  20. data/lib/view_component/cache_digest/resolver.rb +59 -38
  21. data/lib/view_component/cache_digest.rb +161 -53
  22. data/lib/view_component/cacheable.rb +16 -18
  23. data/lib/view_component/collection.rb +4 -9
  24. data/lib/view_component/compile_cache.rb +44 -5
  25. data/lib/view_component/compiler.rb +50 -2
  26. data/lib/view_component/config.rb +114 -8
  27. data/lib/view_component/engine.rb +1 -2
  28. data/lib/view_component/errors.rb +16 -0
  29. data/lib/view_component/slot.rb +12 -0
  30. data/lib/view_component/slotable.rb +28 -4
  31. data/lib/view_component/template.rb +16 -3
  32. data/lib/view_component/test_helpers.rb +38 -16
  33. data/lib/view_component/translatable.rb +34 -0
  34. data/lib/view_component/version.rb +1 -1
  35. metadata +1 -1
@@ -1,9 +1,102 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "view_component/deprecation"
3
+ require "view_component/errors"
4
4
 
5
5
  module ViewComponent
6
6
  class Config
7
+ # An `ActiveSupport::OrderedOptions` that raises when an option it doesn't
8
+ # know about is set, rather than silently storing it.
9
+ class Options < ActiveSupport::OrderedOptions # :nodoc:
10
+ ROOT_NAMESPACE = "config.view_component"
11
+
12
+ def initialize(namespace, allowed_keys, default: nil, renamed: {}, removed: {})
13
+ super(default)
14
+ @namespace = namespace
15
+ @allowed_keys = allowed_keys
16
+ @renamed = renamed
17
+ @removed = removed
18
+ @nested_options = {}
19
+ end
20
+
21
+ def []=(key, value)
22
+ key = key.to_sym
23
+
24
+ if @allowed_keys.include?(key)
25
+ @nested_options[key] ||= value if value.is_a?(Options)
26
+ if (nested_options = @nested_options[key])
27
+ raise TypeError, "#{@namespace}.#{key} must be a Hash" unless value.is_a?(Hash)
28
+
29
+ value = nested_options.dup.clear.tap do |options|
30
+ value.each_pair { |nested_key, nested_value| options[nested_key] = nested_value }
31
+ end
32
+ end
33
+
34
+ super
35
+ else
36
+ raise UnknownConfigOptionError.new(
37
+ "#{@namespace}.#{key}",
38
+ renamed_to: @renamed[key] && "#{ROOT_NAMESPACE}.#{@renamed[key]}",
39
+ removed: @removed.key?(key),
40
+ hint: @removed[key],
41
+ valid_options: @allowed_keys
42
+ )
43
+ end
44
+ end
45
+
46
+ alias_method :store, :[]=
47
+
48
+ def merge!(*others)
49
+ others.each do |other|
50
+ {}.merge!(other).each_pair do |key, value|
51
+ key = key.to_sym
52
+ value = yield(key, self[key], value) if block_given? && key?(key)
53
+ self[key] = value
54
+ end
55
+ end
56
+ self
57
+ end
58
+
59
+ alias_method :update, :merge!
60
+
61
+ def replace(other)
62
+ replacement = dup.clear
63
+ replacement.merge!(other)
64
+ super(replacement)
65
+ end
66
+
67
+ def transform_keys!(*args, &block)
68
+ return enum_for(:transform_keys!) if args.empty? && !block
69
+
70
+ replace(to_h.transform_keys!(*args, &block))
71
+ end
72
+
73
+ def transform_values!(&block)
74
+ return enum_for(:transform_values!) unless block
75
+
76
+ replace(to_h.transform_values!(&block))
77
+ end
78
+ end
79
+
80
+ # Options renamed in 4.0, mapped to their new name.
81
+ RENAMED_OPTIONS = {
82
+ preview_controller: "previews.controller",
83
+ preview_route: "previews.route",
84
+ preview_paths: "previews.paths",
85
+ show_previews: "previews.enabled",
86
+ default_preview_layout: "previews.default_layout",
87
+ component_parent_class: "generate.parent_class",
88
+ view_component_path: "generate.path"
89
+ }.freeze
90
+
91
+ # Options removed in 4.0, mapped to a hint about what to do instead.
92
+ REMOVED_OPTIONS = {
93
+ test_controller: "Use the `vc_test_controller_class` test helper instead.",
94
+ render_monkey_patch_enabled: nil,
95
+ use_deprecated_instrumentation_name: "Events always use the `render.view_component` name.",
96
+ capture_compatibility_patch_enabled: nil,
97
+ show_previews_source: "Consider using Lookbook instead."
98
+ }.freeze
99
+
7
100
  class << self
8
101
  # `new` without any arguments initializes the default configuration, but
9
102
  # it's important to differentiate in case that's no longer the case in
@@ -11,11 +104,16 @@ module ViewComponent
11
104
  alias_method :default, :new
12
105
 
13
106
  def defaults
14
- ActiveSupport::OrderedOptions.new.merge!({
15
- generate: default_generate_options,
16
- previews: default_previews_options,
17
- instrumentation_enabled: false
18
- })
107
+ options = Options.new(
108
+ Options::ROOT_NAMESPACE,
109
+ %i[generate previews instrumentation_enabled],
110
+ renamed: RENAMED_OPTIONS,
111
+ removed: REMOVED_OPTIONS
112
+ )
113
+ options.generate = default_generate_options
114
+ options.previews = default_previews_options
115
+ options.instrumentation_enabled = false
116
+ options
19
117
  end
20
118
 
21
119
  # @!attribute generate
@@ -156,14 +254,22 @@ module ViewComponent
156
254
  end
157
255
 
158
256
  def default_generate_options
159
- options = ActiveSupport::OrderedOptions.new(false)
257
+ options = Options.new(
258
+ "#{Options::ROOT_NAMESPACE}.generate",
259
+ %i[path preview_path parent_class sidecar stimulus_controller typescript locale distinct_locale_files preview use_component_path_for_rspec_tests],
260
+ default: false,
261
+ renamed: {component_parent_class: RENAMED_OPTIONS.fetch(:component_parent_class)}
262
+ )
160
263
  options.preview_path = ""
161
264
  options.path = "app/components"
162
265
  options
163
266
  end
164
267
 
165
268
  def default_previews_options
166
- options = ActiveSupport::OrderedOptions.new
269
+ options = Options.new(
270
+ "#{Options::ROOT_NAMESPACE}.previews",
271
+ %i[controller route enabled default_layout paths]
272
+ )
167
273
  options.controller = "ViewComponentsController"
168
274
  options.route = "/rails/view_components"
169
275
  options.enabled = defined?(Rails.env) && (Rails.env.development? || Rails.env.test?)
@@ -150,10 +150,9 @@ module ViewComponent
150
150
  ActiveSupport.on_load(:action_view) do
151
151
  next if ActionView::Base.cache_template_loading
152
152
 
153
- template_watcher = nil
153
+ template_watcher = CompileCache.template_watcher(app.config.file_watcher)
154
154
 
155
155
  app.executor.to_run :before do
156
- template_watcher ||= CompileCache.template_watcher(app.config.file_watcher)
157
156
  template_watcher.execute_if_updated
158
157
  end
159
158
  end
@@ -267,4 +267,20 @@ module ViewComponent
267
267
  super(MESSAGE.gsub("COMPONENT", component_name.to_s))
268
268
  end
269
269
  end
270
+
271
+ class UnknownConfigOptionError < StandardError
272
+ def initialize(option, renamed_to: nil, removed: false, hint: nil, valid_options: [])
273
+ message =
274
+ if renamed_to
275
+ "`#{option}` was renamed to `#{renamed_to}` in ViewComponent 4.0."
276
+ elsif removed
277
+ "`#{option}` was removed in ViewComponent 4.0."
278
+ else
279
+ "`#{option}` isn't a ViewComponent configuration option. " \
280
+ "Valid options are: #{valid_options.join(", ")}."
281
+ end
282
+
283
+ super([message, hint].compact.join(" "))
284
+ end
285
+ end
270
286
  end
@@ -12,6 +12,18 @@ module ViewComponent
12
12
  @parent = parent
13
13
  end
14
14
 
15
+ # @private
16
+ def __vc_dup(parent)
17
+ dup.tap do |copy|
18
+ copy.instance_variable_set(:@parent, parent)
19
+ copy.remove_instance_variable(:@content) if defined?(@content)
20
+ if __vc_component_instance?
21
+ component = @__vc_component_instance
22
+ copy.__vc_component_instance = component.is_a?(Base) ? component.__vc_dup_for_render : component.dup
23
+ end
24
+ end
25
+ end
26
+
15
27
  def content?
16
28
  return true if defined?(@__vc_content) && @__vc_content.present?
17
29
  return true if defined?(@__vc_content_set_by_with_content) && @__vc_content_set_by_with_content.present?
@@ -216,9 +216,9 @@ module ViewComponent
216
216
  def __vc_register_default_slots
217
217
  registered_slots.each do |slot_name, config|
218
218
  default_method_name = :"default_#{slot_name}"
219
- config[:default_method] = instance_methods.find { |method_name| method_name == default_method_name }
220
-
221
- registered_slots[slot_name] = config
219
+ registered_slots[slot_name] = config.merge(
220
+ default_method: method_defined?(default_method_name) ? default_method_name : nil
221
+ )
222
222
  end
223
223
  end
224
224
 
@@ -361,6 +361,9 @@ module ViewComponent
361
361
 
362
362
  # If there is a default method for the slot, call it
363
363
  if (default_method = registered_slots[slot_name][:default_method])
364
+ @__vc_default_slots ||= {}
365
+ return @__vc_default_slots[slot_name] if @__vc_default_slots.key?(slot_name)
366
+
364
367
  renderable_value = send(default_method)
365
368
  slot = Slot.new(self)
366
369
 
@@ -370,13 +373,34 @@ module ViewComponent
370
373
  slot.__vc_content = renderable_value
371
374
  end
372
375
 
373
- slot
376
+ @__vc_default_slots[slot_name] = slot
374
377
  elsif self.class.registered_slots[slot_name][:collection]
375
378
  # If empty slot is a collection, return an empty array
376
379
  []
377
380
  end
378
381
  end
379
382
 
383
+ # @private
384
+ # Collection spacers need independent slots whose parent is the new copy.
385
+ def __vc_dup_for_render
386
+ __vc_check_reused_instance!
387
+
388
+ dup.tap do |copy|
389
+ %i[@__vc_set_slots @__vc_default_slots].each do |variable|
390
+ next unless instance_variable_defined?(variable)
391
+
392
+ slots = instance_variable_get(variable).transform_values do |slot|
393
+ if slot.is_a?(Array)
394
+ slot.map { |item| item.__vc_dup(copy) }
395
+ else
396
+ slot.__vc_dup(copy)
397
+ end
398
+ end
399
+ copy.instance_variable_set(variable, slots)
400
+ end
401
+ end
402
+ end
403
+
380
404
  def __vc_set_slot(slot_name, slot_definition = nil, *args, **kwargs, &block)
381
405
  slot_definition ||= self.class.registered_slots[slot_name]
382
406
  slot = Slot.new(self)
@@ -35,9 +35,9 @@ module ViewComponent
35
35
  :file
36
36
  end
37
37
 
38
- # Load file each time we look up #source in case the file has been modified
38
+ # Only reuse file contents while eager loading; otherwise reflect edits.
39
39
  def source
40
- ::File.read(@path)
40
+ CompileCache.read_file(@path)
41
41
  end
42
42
 
43
43
  private
@@ -137,6 +137,19 @@ module ViewComponent
137
137
  end
138
138
  end
139
139
 
140
+ class Inherited < InlineCall
141
+ def initialize(component:, template:)
142
+ super(component: component, method_name: template.call_method_name, defined_on_self: false)
143
+ @details = template.details
144
+ @path = template.path
145
+ @template = template
146
+ end
147
+
148
+ def compile_to_component
149
+ @component.define_method(safe_method_name, @component.instance_method(@template.safe_method_name))
150
+ end
151
+ end
152
+
140
153
  def compile_to_component
141
154
  @component.silence_redefinition_of_method(call_method_name)
142
155
 
@@ -184,7 +197,7 @@ module ViewComponent
184
197
  end
185
198
 
186
199
  def safe_method_name
187
- "_#{call_method_name}_#{@component.name.underscore.gsub("/", "__")}"
200
+ "_#{call_method_name}_#{format}_#{@component.name.underscore.gsub("/", "__")}"
188
201
  end
189
202
 
190
203
  def normalized_variant_name
@@ -85,19 +85,21 @@ module ViewComponent
85
85
  #
86
86
  # In RSpec, `Preview` is appended to `described_class`.
87
87
  def render_preview(name, from: __vc_test_helpers_preview_class, params: {})
88
- previews_controller = __vc_test_helpers_build_controller(Rails.application.config.view_component.previews.controller.constantize)
89
-
90
- # From what I can tell, it's not possible to overwrite all request parameters
91
- # at once, so we set them individually here.
92
- params.each do |k, v|
93
- previews_controller.request.params[k] = v
94
- end
95
-
96
- previews_controller.request.params[:path] = "#{from.preview_name}/#{name}"
88
+ @page = nil
89
+ controller_class = Rails.application.config.view_component.previews.controller.constantize
90
+ request = ActionDispatch::TestRequest.new(vc_test_request.env.dup)
91
+ query_parameters = vc_test_request.query_parameters.deep_dup.merge(params.stringify_keys)
92
+ request.path_parameters = vc_test_request.path_parameters.except(*params.keys.map(&:to_sym)).merge(
93
+ controller: controller_class.controller_path,
94
+ action: "previews",
95
+ path: "#{from.preview_name}/#{name}"
96
+ )
97
+ request.set_header("action_dispatch.request.query_parameters", query_parameters)
98
+ request.set_header(Rack::QUERY_STRING, query_parameters.to_query)
99
+ previews_controller = __vc_test_helpers_build_controller(controller_class, request: request)
97
100
  previews_controller.set_response!(ActionDispatch::Response.new)
98
- result = previews_controller.previews
99
-
100
- @rendered_content = result
101
+ previews_controller.process(:previews)
102
+ @rendered_content = previews_controller.response.body
101
103
 
102
104
  Nokogiri::HTML5.fragment(@rendered_content)
103
105
  end
@@ -149,11 +151,14 @@ module ViewComponent
149
151
  # @param klass [Class<ActionController::Base>] The controller to be used.
150
152
  def with_controller_class(klass)
151
153
  old_controller = defined?(@vc_test_controller) && @vc_test_controller
154
+ old_view_context = defined?(@vc_test_view_context) && @vc_test_view_context
152
155
 
156
+ @vc_test_view_context = nil
153
157
  @vc_test_controller = __vc_test_helpers_build_controller(klass)
154
158
  yield
155
159
  ensure
156
160
  @vc_test_controller = old_controller
161
+ @vc_test_view_context = old_view_context
157
162
  end
158
163
 
159
164
  # Set format of the current request
@@ -211,15 +216,21 @@ module ViewComponent
211
216
  # @param method [String] The request method to set for the current request.
212
217
  # @param protocol [Symbol] The protocol to set for the current request (e.g., `:http` or `:https`).
213
218
  def with_request_url(full_path, host: nil, method: nil, protocol: nil)
214
- old_request_host = vc_test_request.host
219
+ old_request_host = vc_test_request.get_header("HTTP_HOST")
215
220
  old_request_method = vc_test_request.request_method
216
221
  old_request_path_info = vc_test_request.path_info
217
222
  old_request_path_parameters = vc_test_request.path_parameters
218
223
  old_request_query_parameters = vc_test_request.query_parameters
219
224
  old_request_query_string = vc_test_request.query_string
225
+ old_request_fullpath = vc_test_request.fullpath
226
+ old_request_original_fullpath = vc_test_request.original_fullpath
220
227
  old_request_format = vc_test_request.format.symbol
221
228
  old_request_scheme = vc_test_request.scheme
229
+ old_request_protocol = vc_test_request.instance_variable_get(:@protocol)
230
+ old_request_port = vc_test_request.instance_variable_get(:@port)
222
231
  old_controller = defined?(@vc_test_controller) && @vc_test_controller
232
+ old_controller_params = old_controller.params if old_controller
233
+ old_controller_url_options = old_controller.instance_variable_get(:@_url_options) if old_controller
223
234
 
224
235
  path, query = full_path.split("?", 2)
225
236
  vc_test_request.instance_variable_set(:@fullpath, full_path)
@@ -227,21 +238,32 @@ module ViewComponent
227
238
  vc_test_request.host = host if host
228
239
  vc_test_request.request_method = method if method
229
240
  vc_test_request.set_header(Rack::RACK_URL_SCHEME, protocol.to_s) if protocol
241
+ vc_test_request.instance_variable_set(:@protocol, nil)
242
+ vc_test_request.instance_variable_set(:@port, nil)
230
243
  vc_test_request.path_info = path
231
244
  vc_test_request.path_parameters = Rails.application.routes.recognize_path_with_request(vc_test_request, path, {})
232
245
  vc_test_request.set_header("action_dispatch.request.query_parameters",
233
246
  Rack::Utils.parse_nested_query(query).with_indifferent_access)
234
247
  vc_test_request.set_header(Rack::QUERY_STRING, query)
248
+ old_controller.params = nil if old_controller
249
+ old_controller&.instance_variable_set(:@_url_options, nil)
235
250
  yield
236
251
  ensure
237
- vc_test_request.host = old_request_host
252
+ vc_test_request.set_header("HTTP_HOST", old_request_host)
238
253
  vc_test_request.request_method = old_request_method
239
254
  vc_test_request.set_header(Rack::RACK_URL_SCHEME, old_request_scheme)
255
+ vc_test_request.instance_variable_set(:@protocol, old_request_protocol)
256
+ vc_test_request.instance_variable_set(:@port, old_request_port)
240
257
  vc_test_request.path_info = old_request_path_info
241
258
  vc_test_request.path_parameters = old_request_path_parameters
242
259
  vc_test_request.set_header("action_dispatch.request.query_parameters", old_request_query_parameters)
243
260
  vc_test_request.set_header(Rack::QUERY_STRING, old_request_query_string)
261
+ vc_test_request.instance_variable_set(:@fullpath, old_request_fullpath)
262
+ vc_test_request.instance_variable_set(:@original_fullpath, old_request_original_fullpath)
244
263
  vc_test_request.format = old_request_format
264
+ old_controller.params = old_controller_params if old_controller
265
+ @vc_test_controller&.instance_variable_set(:@_url_options, nil)
266
+ old_controller&.instance_variable_set(:@_url_options, old_controller_url_options)
245
267
  @vc_test_controller = old_controller
246
268
  end
247
269
 
@@ -298,8 +320,8 @@ module ViewComponent
298
320
  # Note: We prefix private methods here to prevent collisions in consumer's tests.
299
321
  private
300
322
 
301
- def __vc_test_helpers_build_controller(klass)
302
- klass.new.tap { |c| c.request = vc_test_request }.extend(Rails.application.routes.url_helpers)
323
+ def __vc_test_helpers_build_controller(klass, request: vc_test_request)
324
+ klass.new.tap { |c| c.request = request }.extend(Rails.application.routes.url_helpers)
303
325
  end
304
326
 
305
327
  def __vc_test_helpers_preview_class
@@ -14,6 +14,34 @@ module ViewComponent
14
14
  TRANSLATION_EXTENSIONS = %w[yml yaml].freeze
15
15
  private_constant :TRANSLATION_EXTENSIONS
16
16
 
17
+ # Like Rails' translation helper, use a sentinel that survives cache serialization.
18
+ MISSING_TRANSLATION = -(2**61)
19
+ private_constant :MISSING_TRANSLATION
20
+
21
+ def self.symbolic_defaults?(options)
22
+ default = options[:default]
23
+ default.is_a?(Symbol) || (default.is_a?(Array) && default.any? { |value| value.is_a?(Symbol) })
24
+ end
25
+
26
+ def self.translate_with_defaults(translator, key, options)
27
+ defaults = options[:default].is_a?(Array) ? options[:default].compact : [options[:default]]
28
+ options = options.except(:default)
29
+
30
+ translated = translator.translate(key, **options, default: MISSING_TRANSLATION)
31
+ return translated unless translated == MISSING_TRANSLATION
32
+
33
+ defaults.each do |default|
34
+ translated = if default.is_a?(Symbol)
35
+ translator.translate(default, **options, default: MISSING_TRANSLATION)
36
+ else
37
+ translator.translate(key, **options, default: [default, MISSING_TRANSLATION])
38
+ end
39
+ return translated unless translated == MISSING_TRANSLATION
40
+ end
41
+
42
+ translator.translate(key, **options)
43
+ end
44
+
17
45
  included do
18
46
  class_attribute :__vc_i18n_backend, instance_writer: false, instance_predicate: false
19
47
  end
@@ -53,9 +81,13 @@ module ViewComponent
53
81
 
54
82
  def translate(key = nil, **options)
55
83
  return key.map { |k| translate(k, **options) } if key.is_a?(Array)
84
+ symbolic_defaults = Translatable.symbolic_defaults?(options)
85
+ return ::I18n.translate(key, **options) if key.nil? && !symbolic_defaults
56
86
 
57
87
  __vc_ensure_compiled
58
88
 
89
+ return Translatable.translate_with_defaults(self, key, options) if symbolic_defaults
90
+
59
91
  locale = options.delete(:locale) || ::I18n.locale
60
92
  key = __vc_i18n_key(key, options.delete(:scope))
61
93
 
@@ -99,6 +131,8 @@ module ViewComponent
99
131
 
100
132
  return @view_context.translate(key, **options) unless __vc_i18n_backend
101
133
  return key.map { |k| translate(k, **options) } if key.is_a?(Array)
134
+ return Translatable.translate_with_defaults(self, key, options) if Translatable.symbolic_defaults?(options)
135
+ return @view_context.translate(key, **options) if key.nil?
102
136
 
103
137
  locale = options.delete(:locale) || ::I18n.locale
104
138
  key = self.class.__vc_i18n_key(key, options.delete(:scope))
@@ -3,7 +3,7 @@
3
3
  module ViewComponent
4
4
  module VERSION
5
5
  MAJOR = 4
6
- MINOR = 20
6
+ MINOR = 21
7
7
  PATCH = 0
8
8
  PRE = nil
9
9
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: view_component
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.20.0
4
+ version: 4.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ViewComponent Team