view_component 2.32.0 → 2.36.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of view_component might be problematic. Click here for more details.

Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/app/assets/vendor/prism.css +196 -0
  4. data/app/assets/vendor/prism.min.js +12 -0
  5. data/app/controllers/view_components_controller.rb +1 -1
  6. data/app/helpers/preview_helper.rb +19 -0
  7. data/app/views/test_mailer/test_email.html.erb +1 -0
  8. data/app/views/view_components/_preview_source.html.erb +17 -0
  9. data/app/views/view_components/preview.html.erb +6 -2
  10. data/{CHANGELOG.md → docs/CHANGELOG.md} +127 -1
  11. data/lib/rails/generators/abstract_generator.rb +29 -0
  12. data/lib/rails/generators/component/component_generator.rb +5 -5
  13. data/lib/rails/generators/erb/component_generator.rb +7 -16
  14. data/lib/rails/generators/haml/component_generator.rb +6 -16
  15. data/lib/rails/generators/slim/component_generator.rb +6 -16
  16. data/lib/view_component.rb +2 -0
  17. data/lib/view_component/base.rb +152 -85
  18. data/lib/view_component/collection.rb +7 -2
  19. data/lib/view_component/compile_cache.rb +1 -0
  20. data/lib/view_component/compiler.rb +87 -53
  21. data/lib/view_component/content_areas.rb +57 -0
  22. data/lib/view_component/engine.rb +31 -3
  23. data/lib/view_component/instrumentation.rb +21 -0
  24. data/lib/view_component/preview.rb +19 -8
  25. data/lib/view_component/previewable.rb +16 -18
  26. data/lib/view_component/slot_v2.rb +34 -27
  27. data/lib/view_component/slotable.rb +2 -1
  28. data/lib/view_component/slotable_v2.rb +58 -24
  29. data/lib/view_component/test_helpers.rb +7 -1
  30. data/lib/view_component/translatable.rb +6 -5
  31. data/lib/view_component/version.rb +1 -1
  32. data/lib/view_component/with_content_helper.rb +5 -2
  33. data/lib/yard/mattr_accessor_handler.rb +19 -0
  34. metadata +76 -39
@@ -5,6 +5,7 @@ require "action_view/renderer/collection_renderer" if Rails.version.to_f >= 6.1
5
5
  module ViewComponent
6
6
  class Collection
7
7
  attr_reader :component
8
+
8
9
  delegate :format, to: :component
9
10
 
10
11
  def render_in(view_context, &block)
@@ -16,7 +17,7 @@ module ViewComponent
16
17
  content = component.new(**component_options(item, iterator)).render_in(view_context, &block)
17
18
  iterator.iterate!
18
19
  content
19
- end.join.html_safe
20
+ end.join.html_safe # rubocop:disable Rails/OutputSafety
20
21
  end
21
22
 
22
23
  private
@@ -31,13 +32,17 @@ module ViewComponent
31
32
  if object.respond_to?(:to_ary)
32
33
  object.to_ary
33
34
  else
34
- raise ArgumentError.new("The value of the argument isn't a valid collection. Make sure it responds to to_ary: #{object.inspect}")
35
+ raise ArgumentError.new(
36
+ "The value of the first argument passed to `with_collection` isn't a valid collection. " \
37
+ "Make sure it responds to `to_ary`."
38
+ )
35
39
  end
36
40
  end
37
41
 
38
42
  def component_options(item, iterator)
39
43
  item_options = { component.collection_parameter => item }
40
44
  item_options[component.collection_counter_parameter] = iterator.index + 1 if component.counter_argument_present?
45
+ item_options[component.collection_iteration_parameter] = iterator if component.iteration_argument_present?
41
46
 
42
47
  @options.merge(item_options)
43
48
  end
@@ -7,6 +7,7 @@ module ViewComponent
7
7
  mattr_accessor :cache, instance_reader: false, instance_accessor: false do
8
8
  Set.new
9
9
  end
10
+
10
11
  module_function
11
12
 
12
13
  def register(klass)
@@ -16,17 +16,22 @@ module ViewComponent
16
16
  subclass_instance_methods = component_class.instance_methods(false)
17
17
 
18
18
  if subclass_instance_methods.include?(:with_content) && raise_errors
19
- raise ViewComponent::ComponentError.new("#{component_class} implements a reserved method, `with_content`.")
19
+ raise ViewComponent::ComponentError.new(
20
+ "#{component_class} implements a reserved method, `#with_content`.\n\n" \
21
+ "To fix this issue, change the name of the method."
22
+ )
20
23
  end
21
24
 
22
25
  if template_errors.present?
23
26
  raise ViewComponent::TemplateError.new(template_errors) if raise_errors
27
+
24
28
  return false
25
29
  end
26
30
 
27
31
  if subclass_instance_methods.include?(:before_render_check)
28
32
  ActiveSupport::Deprecation.warn(
29
- "`before_render_check` will be removed in v3.0.0. Use `before_render` instead."
33
+ "`#before_render_check` will be removed in v3.0.0.\n\n" \
34
+ "To fix this issue, use `#before_render` instead."
30
35
  )
31
36
  end
32
37
 
@@ -39,7 +44,10 @@ module ViewComponent
39
44
  # Remove existing compiled template methods,
40
45
  # as Ruby warns when redefining a method.
41
46
  method_name = call_method_name(template[:variant])
42
- component_class.send(:undef_method, method_name.to_sym) if component_class.instance_methods.include?(method_name.to_sym)
47
+
48
+ if component_class.instance_methods.include?(method_name.to_sym)
49
+ component_class.send(:undef_method, method_name.to_sym)
50
+ end
43
51
 
44
52
  component_class.class_eval <<-RUBY, template[:path], -1
45
53
  def #{method_name}
@@ -61,7 +69,9 @@ module ViewComponent
61
69
  attr_reader :component_class
62
70
 
63
71
  def define_render_template_for
64
- component_class.send(:undef_method, :render_template_for) if component_class.instance_methods.include?(:render_template_for)
72
+ if component_class.instance_methods.include?(:render_template_for)
73
+ component_class.send(:undef_method, :render_template_for)
74
+ end
65
75
 
66
76
  variant_elsifs = variants.compact.uniq.map do |variant|
67
77
  "elsif variant.to_sym == :#{variant}\n #{call_method_name(variant)}"
@@ -77,72 +87,90 @@ module ViewComponent
77
87
  end
78
88
  end
79
89
  RUBY
80
-
81
90
  end
82
91
 
83
92
  def template_errors
84
- @_template_errors ||= begin
85
- errors = []
93
+ @__vc_template_errors ||=
94
+ begin
95
+ errors = []
86
96
 
87
- if (templates + inline_calls).empty?
88
- errors << "Could not find a template file or inline render method for #{component_class}."
89
- end
97
+ if (templates + inline_calls).empty?
98
+ errors << "Could not find a template file or inline render method for #{component_class}."
99
+ end
90
100
 
91
- if templates.count { |template| template[:variant].nil? } > 1
92
- errors << "More than one template found for #{component_class}. There can only be one default template file per component."
93
- end
101
+ if templates.count { |template| template[:variant].nil? } > 1
102
+ errors <<
103
+ "More than one template found for #{component_class}. " \
104
+ "There can only be one default template file per component."
105
+ end
94
106
 
95
- invalid_variants = templates
96
- .group_by { |template| template[:variant] }
97
- .map { |variant, grouped| variant if grouped.length > 1 }
98
- .compact
99
- .sort
107
+ invalid_variants =
108
+ templates.
109
+ group_by { |template| template[:variant] }.
110
+ map { |variant, grouped| variant if grouped.length > 1 }.
111
+ compact.
112
+ sort
113
+
114
+ unless invalid_variants.empty?
115
+ errors <<
116
+ "More than one template found for #{'variant'.pluralize(invalid_variants.count)} " \
117
+ "#{invalid_variants.map { |v| "'#{v}'" }.to_sentence} in #{component_class}. " \
118
+ "There can only be one template file per variant."
119
+ end
100
120
 
101
- unless invalid_variants.empty?
102
- errors << "More than one template found for #{'variant'.pluralize(invalid_variants.count)} #{invalid_variants.map { |v| "'#{v}'" }.to_sentence} in #{component_class}. There can only be one template file per variant."
103
- end
121
+ if templates.find { |template| template[:variant].nil? } && inline_calls_defined_on_self.include?(:call)
122
+ errors <<
123
+ "Template file and inline render method found for #{component_class}. " \
124
+ "There can only be a template file or inline render method per component."
125
+ end
104
126
 
105
- if templates.find { |template| template[:variant].nil? } && inline_calls_defined_on_self.include?(:call)
106
- errors << "Template file and inline render method found for #{component_class}. There can only be a template file or inline render method per component."
107
- end
127
+ duplicate_template_file_and_inline_variant_calls =
128
+ templates.pluck(:variant) & variants_from_inline_calls(inline_calls_defined_on_self)
108
129
 
109
- duplicate_template_file_and_inline_variant_calls =
110
- templates.pluck(:variant) & variants_from_inline_calls(inline_calls_defined_on_self)
130
+ unless duplicate_template_file_and_inline_variant_calls.empty?
131
+ count = duplicate_template_file_and_inline_variant_calls.count
111
132
 
112
- unless duplicate_template_file_and_inline_variant_calls.empty?
113
- count = duplicate_template_file_and_inline_variant_calls.count
133
+ errors <<
134
+ "Template #{'file'.pluralize(count)} and inline render #{'method'.pluralize(count)} " \
135
+ "found for #{'variant'.pluralize(count)} " \
136
+ "#{duplicate_template_file_and_inline_variant_calls.map { |v| "'#{v}'" }.to_sentence} " \
137
+ "in #{component_class}. " \
138
+ "There can only be a template file or inline render method per variant."
139
+ end
114
140
 
115
- errors << "Template #{'file'.pluralize(count)} and inline render #{'method'.pluralize(count)} found for #{'variant'.pluralize(count)} #{duplicate_template_file_and_inline_variant_calls.map { |v| "'#{v}'" }.to_sentence} in #{component_class}. There can only be a template file or inline render method per variant."
141
+ errors
116
142
  end
117
-
118
- errors
119
- end
120
143
  end
121
144
 
122
145
  def templates
123
- @templates ||= begin
124
- extensions = ActionView::Template.template_handler_extensions
125
-
126
- component_class._sidecar_files(extensions).each_with_object([]) do |path, memo|
127
- pieces = File.basename(path).split(".")
128
- memo << {
129
- path: path,
130
- variant: pieces.second.split("+").second&.to_sym,
131
- handler: pieces.last
132
- }
146
+ @templates ||=
147
+ begin
148
+ extensions = ActionView::Template.template_handler_extensions
149
+
150
+ component_class._sidecar_files(extensions).each_with_object([]) do |path, memo|
151
+ pieces = File.basename(path).split(".")
152
+ memo << {
153
+ path: path,
154
+ variant: pieces.second.split("+").second&.to_sym,
155
+ handler: pieces.last
156
+ }
157
+ end
133
158
  end
134
- end
135
159
  end
136
160
 
137
161
  def inline_calls
138
- @inline_calls ||= begin
139
- # Fetch only ViewComponent ancestor classes to limit the scope of
140
- # finding inline calls
141
- view_component_ancestors =
142
- component_class.ancestors.take_while { |ancestor| ancestor != ViewComponent::Base } - component_class.included_modules
143
-
144
- view_component_ancestors.flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call/) }.uniq
145
- end
162
+ @inline_calls ||=
163
+ begin
164
+ # Fetch only ViewComponent ancestor classes to limit the scope of
165
+ # finding inline calls
166
+ view_component_ancestors =
167
+ (
168
+ component_class.ancestors.take_while { |ancestor| ancestor != ViewComponent::Base } -
169
+ component_class.included_modules
170
+ )
171
+
172
+ view_component_ancestors.flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call/) }.uniq
173
+ end
146
174
  end
147
175
 
148
176
  def inline_calls_defined_on_self
@@ -150,7 +178,7 @@ module ViewComponent
150
178
  end
151
179
 
152
180
  def variants
153
- @_variants = (
181
+ @__vc_variants = (
154
182
  templates.map { |template| template[:variant] } + variants_from_inline_calls(inline_calls)
155
183
  ).compact.uniq
156
184
  end
@@ -168,7 +196,13 @@ module ViewComponent
168
196
  if handler.method(:call).parameters.length > 1
169
197
  handler.call(component_class, template)
170
198
  else
171
- handler.call(OpenStruct.new(source: template, identifier: component_class.identifier, type: component_class.type))
199
+ handler.call(
200
+ OpenStruct.new(
201
+ source: template,
202
+ identifier: component_class.identifier,
203
+ type: component_class.type
204
+ )
205
+ )
172
206
  end
173
207
  end
174
208
 
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ require "view_component/slot"
6
+
7
+ # DEPRECATED - ContentAreas is deprecated and will be removed in v3.0.0
8
+ module ViewComponent
9
+ module ContentAreas
10
+ extend ActiveSupport::Concern
11
+
12
+ # Assign the provided content to the content area accessor
13
+ #
14
+ # @private
15
+ def with(area, content = nil, &block)
16
+ unless content_areas.include?(area)
17
+ raise ArgumentError.new(
18
+ "Unknown content_area '#{area}' for #{self} - expected one of '#{content_areas}'.\n\n" \
19
+ "To fix this issue, add `with_content_area :#{area}` to #{self} or reference " \
20
+ "a valid content area."
21
+ )
22
+ end
23
+
24
+ if block_given?
25
+ content = view_context.capture(&block)
26
+ end
27
+
28
+ instance_variable_set("@#{area}".to_sym, content)
29
+ nil
30
+ end
31
+
32
+ class_methods do
33
+ def with_content_areas(*areas)
34
+ ActiveSupport::Deprecation.warn(
35
+ "`with_content_areas` is deprecated and will be removed in ViewComponent v3.0.0.\n\n" \
36
+ "Use slots (https://viewcomponent.org/guide/slots.html) instead."
37
+ )
38
+
39
+ if areas.include?(:content)
40
+ raise ArgumentError.new(
41
+ "#{self} defines a content area called :content, which is a reserved name. \n\n" \
42
+ "To fix this issue, use another name, such as `:body`."
43
+ )
44
+ end
45
+
46
+ areas.each do |area|
47
+ define_method area.to_sym do
48
+ content unless content_evaluated? # ensure content is loaded so content_areas will be defined
49
+ instance_variable_get(:"@#{area}") if instance_variable_defined?(:"@#{area}")
50
+ end
51
+ end
52
+
53
+ self.content_areas = areas
54
+ end
55
+ end
56
+ end
57
+ end
@@ -13,6 +13,7 @@ module ViewComponent
13
13
 
14
14
  options.render_monkey_patch_enabled = true if options.render_monkey_patch_enabled.nil?
15
15
  options.show_previews = Rails.env.development? || Rails.env.test? if options.show_previews.nil?
16
+ options.instrumentation_enabled = false if options.instrumentation_enabled.nil?
16
17
  options.preview_route ||= ViewComponent::Base.preview_route
17
18
  options.preview_controller ||= ViewComponent::Base.preview_controller
18
19
 
@@ -30,7 +31,17 @@ module ViewComponent
30
31
  end
31
32
 
32
33
  ActiveSupport.on_load(:view_component) do
33
- options.each { |k, v| send("#{k}=", v) }
34
+ options.each { |k, v| send("#{k}=", v) if respond_to?("#{k}=") }
35
+ end
36
+ end
37
+
38
+ initializer "view_component.enable_instrumentation" do |app|
39
+ ActiveSupport.on_load(:view_component) do
40
+ if app.config.view_component.instrumentation_enabled.present?
41
+ # :nocov:
42
+ ViewComponent::Base.prepend(ViewComponent::Instrumentation)
43
+ # :nocov:
44
+ end
34
45
  end
35
46
  end
36
47
 
@@ -86,6 +97,12 @@ module ViewComponent
86
97
  end
87
98
  end
88
99
 
100
+ initializer "static assets" do |app|
101
+ if app.config.view_component.show_previews
102
+ app.middleware.insert_before(::ActionDispatch::Static, ::ActionDispatch::Static, "#{root}/app/assets/vendor")
103
+ end
104
+ end
105
+
89
106
  config.after_initialize do |app|
90
107
  options = app.config.view_component
91
108
 
@@ -93,8 +110,19 @@ module ViewComponent
93
110
  app.routes.prepend do
94
111
  preview_controller = options.preview_controller.sub(/Controller$/, "").underscore
95
112
 
96
- get options.preview_route, to: "#{preview_controller}#index", as: :preview_view_components, internal: true
97
- get "#{options.preview_route}/*path", to: "#{preview_controller}#previews", as: :preview_view_component, internal: true
113
+ get(
114
+ options.preview_route,
115
+ to: "#{preview_controller}#index",
116
+ as: :preview_view_components,
117
+ internal: true
118
+ )
119
+
120
+ get(
121
+ "#{options.preview_route}/*path",
122
+ to: "#{preview_controller}#previews",
123
+ as: :preview_view_component,
124
+ internal: true
125
+ )
98
126
  end
99
127
  end
100
128
 
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module ViewComponent # :nodoc:
6
+ module Instrumentation
7
+ def self.included(mod)
8
+ mod.prepend(self)
9
+ end
10
+
11
+ def render_in(view_context, &block)
12
+ ActiveSupport::Notifications.instrument(
13
+ "!render.view_component",
14
+ name: self.class.name,
15
+ identifier: self.class.identifier
16
+ ) do
17
+ super(view_context, &block)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -71,19 +71,30 @@ module ViewComponent # :nodoc:
71
71
 
72
72
  # Returns the relative path (from preview_path) to the preview example template if the template exists
73
73
  def preview_example_template_path(example)
74
- preview_path = Array(preview_paths).detect do |preview_path|
75
- Dir["#{preview_path}/#{preview_name}_preview/#{example}.html.*"].first
76
- end
74
+ preview_path =
75
+ Array(preview_paths).detect do |preview_path|
76
+ Dir["#{preview_path}/#{preview_name}_preview/#{example}.html.*"].first
77
+ end
77
78
 
78
79
  if preview_path.nil?
79
- raise PreviewTemplateError, "preview template for example #{example} does not exist"
80
+ raise(
81
+ PreviewTemplateError,
82
+ "A preview template for example #{example} does not exist.\n\n" \
83
+ "To fix this issue, create a template for the example."
84
+ )
80
85
  end
81
86
 
82
87
  path = Dir["#{preview_path}/#{preview_name}_preview/#{example}.html.*"].first
83
- Pathname.new(path)
84
- .relative_path_from(Pathname.new(preview_path))
85
- .to_s
86
- .sub(/\..*$/, "")
88
+ Pathname.new(path).
89
+ relative_path_from(Pathname.new(preview_path)).
90
+ to_s.
91
+ sub(/\..*$/, "")
92
+ end
93
+
94
+ # Returns the method body for the example from the preview file.
95
+ def preview_source(example)
96
+ source = self.instance_method(example.to_sym).source.split("\n")
97
+ source[1...(source.size - 1)].join("\n")
87
98
  end
88
99
 
89
100
  private
@@ -2,51 +2,49 @@
2
2
 
3
3
  require "active_support/concern"
4
4
 
5
- module ViewComponent # :nodoc:
5
+ module ViewComponent
6
6
  module Previewable
7
7
  extend ActiveSupport::Concern
8
8
 
9
9
  included do
10
- # Set a custom default preview layout through app configuration:
10
+ # Enable or disable component previews:
11
11
  #
12
- # config.view_component.default_preview_layout = "component_preview"
12
+ # config.view_component.show_previews = true
13
13
  #
14
- # This affects preview index pages as well as individual component previews
14
+ # Defaults to `true` in development.
15
+ #
16
+ mattr_accessor :show_previews, instance_writer: false
17
+
18
+ # Set a custom default layout used for preview index and individual previews:
19
+ #
20
+ # config.view_component.default_preview_layout = "component_preview"
15
21
  #
16
22
  mattr_accessor :default_preview_layout, instance_writer: false
17
23
 
18
- # Set the location of component previews through app configuration:
24
+ # Set the location of component previews:
19
25
  #
20
26
  # config.view_component.preview_paths << "#{Rails.root}/lib/component_previews"
21
27
  #
22
28
  mattr_accessor :preview_paths, instance_writer: false
23
29
 
24
- # TODO: deprecated, remove in v3.0.0
30
+ # @deprecated Use `preview_paths` instead. Will be removed in v3.0.0.
25
31
  mattr_accessor :preview_path, instance_writer: false
26
32
 
27
- # Enable or disable component previews through app configuration:
28
- #
29
- # config.view_component.show_previews = true
30
- #
31
- # Defaults to +true+ for development environment
32
- #
33
- mattr_accessor :show_previews, instance_writer: false
34
-
35
- # Set the entry route for component previews through app configuration:
33
+ # Set the entry route for component previews:
36
34
  #
37
35
  # config.view_component.preview_route = "/previews"
38
36
  #
39
- # Defaults to +/rails/view_components+ when `show_previews' is enabled
37
+ # Defaults to `/rails/view_components` when `show_previews` is enabled.
40
38
  #
41
39
  mattr_accessor :preview_route, instance_writer: false do
42
40
  "/rails/view_components"
43
41
  end
44
42
 
45
- # Set the controller to be used for previewing components through app configuration:
43
+ # Set the controller used for previewing components:
46
44
  #
47
45
  # config.view_component.preview_controller = "MyPreviewController"
48
46
  #
49
- # Defaults to the provided +ViewComponentsController+
47
+ # Defaults to `ViewComponentsController`.
50
48
  #
51
49
  mattr_accessor :preview_controller, instance_writer: false do
52
50
  "ViewComponentsController"