view_component 2.4.0 → 2.8.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.

@@ -2,8 +2,8 @@
2
2
 
3
3
  require "rails/application_controller"
4
4
 
5
- class Rails::ViewComponentsController < Rails::ApplicationController # :nodoc:
6
- prepend_view_path File.expand_path("../../../lib/railties/lib/rails/templates/rails", __dir__)
5
+ class ViewComponentsController < Rails::ApplicationController # :nodoc:
6
+ prepend_view_path File.expand_path("../views", __dir__)
7
7
 
8
8
  around_action :set_locale, only: :previews
9
9
  before_action :find_preview, only: :previews
@@ -16,26 +16,19 @@ class Rails::ViewComponentsController < Rails::ApplicationController # :nodoc:
16
16
  def index
17
17
  @previews = ViewComponent::Preview.all
18
18
  @page_title = "Component Previews"
19
- # rubocop:disable GitHub/RailsControllerRenderPathsExist
20
- render "components/index"
21
- # rubocop:enable GitHub/RailsControllerRenderPathsExist
22
19
  end
23
20
 
24
21
  def previews
25
22
  if params[:path] == @preview.preview_name
26
23
  @page_title = "Component Previews for #{@preview.preview_name}"
27
- # rubocop:disable GitHub/RailsControllerRenderPathsExist
28
- render "components/previews"
29
- # rubocop:enable GitHub/RailsControllerRenderPathsExist
24
+ render "view_components/previews"
30
25
  else
31
26
  prepend_application_view_paths
32
27
  @example_name = File.basename(params[:path])
33
- @render_args = @preview.render_args(@example_name)
28
+ @render_args = @preview.render_args(@example_name, params: params.permit!)
34
29
  layout = @render_args[:layout]
35
30
  opts = layout.nil? ? {} : { layout: layout }
36
- # rubocop:disable GitHub/RailsControllerRenderPathsExist
37
- render "components/preview", **opts
38
- # rubocop:enable GitHub/RailsControllerRenderPathsExist
31
+ render "view_components/preview", opts
39
32
  end
40
33
  end
41
34
 
@@ -0,0 +1,8 @@
1
+ <% @previews.each do |preview| %>
2
+ <h3><%= link_to preview.preview_name.titleize, preview_view_component_path(preview.preview_name) %></h3>
3
+ <ul>
4
+ <% preview.examples.each do |preview_example| %>
5
+ <li><%= link_to preview_example, preview_view_component_path("#{preview.preview_name}/#{preview_example}") %></li>
6
+ <% end %>
7
+ </ul>
8
+ <% end %>
@@ -0,0 +1 @@
1
+ <%= render(@render_args[:component], @render_args[:args], &@render_args[:block])%>
@@ -1,6 +1,6 @@
1
1
  <h3><%= @preview.preview_name.titleize %></h3>
2
2
  <ul>
3
3
  <% @preview.examples.each do |example| %>
4
- <li><%= link_to example, "/rails/view_components/#{@preview.preview_name}/#{example}" %></li>
4
+ <li><%= link_to example, preview_view_component_path("#{@preview.preview_name}/#{example}") %></li>
5
5
  <% end %>
6
6
  </ul>
@@ -10,15 +10,11 @@ module ViewComponent
10
10
  include ActiveSupport::Configurable
11
11
  include ViewComponent::Previewable
12
12
 
13
+ # For CSRF authenticity tokens in forms
13
14
  delegate :form_authenticity_token, :protect_against_forgery?, to: :helpers
14
15
 
15
- class_attribute :content_areas, default: []
16
- self.content_areas = [] # default doesn't work until Rails 5.2
17
-
18
- # Render a component collection.
19
- def self.with_collection(*args)
20
- Collection.new(self, *args)
21
- end
16
+ class_attribute :content_areas
17
+ self.content_areas = [] # class_attribute:default doesn't work until Rails 5.2
22
18
 
23
19
  # Entrypoint for rendering components.
24
20
  #
@@ -45,20 +41,32 @@ module ViewComponent
45
41
  # <span title="greeting">Hello, world!</span>
46
42
  #
47
43
  def render_in(view_context, &block)
48
- self.class.compile!
44
+ self.class.compile(raise_errors: true)
45
+
49
46
  @view_context = view_context
50
- @view_renderer ||= view_context.view_renderer
51
47
  @lookup_context ||= view_context.lookup_context
48
+
49
+ # required for path helpers in older Rails versions
50
+ @view_renderer ||= view_context.view_renderer
51
+
52
+ # For content_for
52
53
  @view_flow ||= view_context.view_flow
54
+
55
+ # For i18n
53
56
  @virtual_path ||= virtual_path
57
+
58
+ # For template variants (+phone, +desktop, etc.)
54
59
  @variant = @lookup_context.variants.first
55
60
 
61
+ # For caching, such as #cache_if
62
+ @current_template = nil unless defined?(@current_template)
56
63
  old_current_template = @current_template
57
64
  @current_template = self
58
65
 
66
+ # Assign captured content passed to component as a block to @content
59
67
  @content = view_context.capture(self, &block) if block_given?
60
68
 
61
- before_render_check
69
+ before_render
62
70
 
63
71
  if render?
64
72
  send(self.class.call_method_name(@variant))
@@ -69,6 +77,10 @@ module ViewComponent
69
77
  @current_template = old_current_template
70
78
  end
71
79
 
80
+ def before_render
81
+ before_render_check
82
+ end
83
+
72
84
  def before_render_check
73
85
  # noop
74
86
  end
@@ -77,12 +89,10 @@ module ViewComponent
77
89
  true
78
90
  end
79
91
 
80
- def self.short_identifier
81
- @short_identifier ||= defined?(Rails.root) ? source_location.sub("#{Rails.root}/", "") : source_location
82
- end
83
-
84
92
  def initialize(*); end
85
93
 
94
+ # If trying to render a partial or template inside a component,
95
+ # pass the render call to the parent view_context.
86
96
  def render(options = {}, args = {}, &block)
87
97
  if options.is_a?(String) || (options.is_a?(Hash) && options.has_key?(:partial))
88
98
  view_context.render(options, args, &block)
@@ -95,7 +105,7 @@ module ViewComponent
95
105
  @controller ||= view_context.controller
96
106
  end
97
107
 
98
- # Provides a proxy to access helper methods through
108
+ # Provides a proxy to access helper methods
99
109
  def helpers
100
110
  @helpers ||= view_context
101
111
  end
@@ -105,14 +115,17 @@ module ViewComponent
105
115
  self.class.source_location.gsub(%r{(.*app/components)|(\.rb)}, "")
106
116
  end
107
117
 
118
+ # For caching, such as #cache_if
108
119
  def view_cache_dependencies
109
120
  []
110
121
  end
111
122
 
112
- def format # :nodoc:
123
+ # For caching, such as #cache_if
124
+ def format
113
125
  @variant
114
126
  end
115
127
 
128
+ # Assign the provided content to the content area accessor
116
129
  def with(area, content = nil, &block)
117
130
  unless content_areas.include?(area)
118
131
  raise ArgumentError.new "Unknown content_area '#{area}' - expected one of '#{content_areas}'"
@@ -128,6 +141,9 @@ module ViewComponent
128
141
 
129
142
  private
130
143
 
144
+ # Exposes the current request to the component.
145
+ # Use sparingly as doing so introduces coupling
146
+ # that inhibits encapsulation & reuse.
131
147
  def request
132
148
  @request ||= controller.request
133
149
  end
@@ -143,7 +159,18 @@ module ViewComponent
143
159
  class << self
144
160
  attr_accessor :source_location
145
161
 
162
+ # Render a component collection.
163
+ def with_collection(*args)
164
+ Collection.new(self, *args)
165
+ end
166
+
167
+ # Provide identifier for ActionView template annotations
168
+ def short_identifier
169
+ @short_identifier ||= defined?(Rails.root) ? source_location.sub("#{Rails.root}/", "") : source_location
170
+ end
171
+
146
172
  def inherited(child)
173
+ # If we're in Rails, add application url_helpers to the component context
147
174
  if defined?(Rails)
148
175
  child.include Rails.application.routes.url_helpers unless child < Rails.application.routes.url_helpers
149
176
  end
@@ -165,43 +192,78 @@ module ViewComponent
165
192
  end
166
193
 
167
194
  def compiled?
168
- @compiled && ActionView::Base.cache_template_loading
169
- end
195
+ @compiled ||= false
170
196
 
171
- def compile!
172
- compile(raise_template_errors: true)
197
+ @compiled && ActionView::Base.cache_template_loading
173
198
  end
174
199
 
175
200
  # Compile templates to instance methods, assuming they haven't been compiled already.
176
- # We could in theory do this on app boot, at least in production environments.
177
- # Right now this just compiles the first time the component is rendered.
178
- def compile(raise_template_errors: false)
201
+ #
202
+ # Do as much work as possible in this step, as doing so reduces the amount
203
+ # of work done each time a component is rendered.
204
+ def compile(raise_errors: false)
179
205
  return if compiled?
180
206
 
181
207
  if template_errors.present?
182
- raise ViewComponent::TemplateError.new(template_errors) if raise_template_errors
208
+ raise ViewComponent::TemplateError.new(template_errors) if raise_errors
183
209
  return false
184
210
  end
185
211
 
212
+ if instance_methods(false).include?(:before_render_check)
213
+ ActiveSupport::Deprecation.warn(
214
+ "`before_render_check` will be removed in v3.0.0. Use `before_render` instead."
215
+ )
216
+ end
217
+
218
+ # Remove any existing singleton methods,
219
+ # as Ruby warns when redefining a method.
220
+ remove_possible_singleton_method(:variants)
221
+ remove_possible_singleton_method(:collection_parameter)
222
+ remove_possible_singleton_method(:collection_counter_parameter)
223
+ remove_possible_singleton_method(:counter_argument_present?)
224
+
186
225
  define_singleton_method(:variants) do
187
226
  templates.map { |template| template[:variant] } + variants_from_inline_calls(inline_calls)
188
227
  end
189
228
 
229
+ define_singleton_method(:collection_parameter) do
230
+ if provided_collection_parameter
231
+ provided_collection_parameter
232
+ else
233
+ name.demodulize.underscore.chomp("_component").to_sym
234
+ end
235
+ end
236
+
237
+ define_singleton_method(:collection_counter_parameter) do
238
+ "#{collection_parameter}_counter".to_sym
239
+ end
240
+
241
+ define_singleton_method(:counter_argument_present?) do
242
+ instance_method(:initialize).parameters.map(&:second).include?(collection_counter_parameter)
243
+ end
244
+
245
+ validate_collection_parameter! if raise_errors
246
+
190
247
  # If template name annotations are turned on, a line is dynamically
191
248
  # added with a comment. In this case, we want to return a different
192
249
  # starting line number so errors that are raised will point to the
193
250
  # correct line in the component template.
194
251
  line_number =
195
- if ActionView::Base.respond_to?(:annotate_template_file_names) &&
196
- ActionView::Base.annotate_template_file_names
252
+ if ActionView::Base.respond_to?(:annotate_rendered_view_with_filenames) &&
253
+ ActionView::Base.annotate_rendered_view_with_filenames
197
254
  -2
198
255
  else
199
256
  -1
200
257
  end
201
258
 
202
259
  templates.each do |template|
260
+ # Remove existing compiled template methods,
261
+ # as Ruby warns when redefining a method.
262
+ method_name = call_method_name(template[:variant])
263
+ undef_method(method_name.to_sym) if instance_methods.include?(method_name.to_sym)
264
+
203
265
  class_eval <<-RUBY, template[:path], line_number
204
- def #{call_method_name(template[:variant])}
266
+ def #{method_name}
205
267
  @output_buffer = ActionView::OutputBuffer.new
206
268
  #{compiled_template(template[:path])}
207
269
  end
@@ -228,21 +290,38 @@ module ViewComponent
228
290
  if areas.include?(:content)
229
291
  raise ArgumentError.new ":content is a reserved content area name. Please use another name, such as ':body'"
230
292
  end
231
- attr_reader *areas
293
+ attr_reader(*areas)
232
294
  self.content_areas = areas
233
295
  end
234
296
 
235
- # Support overriding this component's collection parameter name
297
+ # Support overriding collection parameter name
236
298
  def with_collection_parameter(param)
237
- @with_collection_parameter = param
299
+ @provided_collection_parameter = param
238
300
  end
239
301
 
240
- def collection_parameter_name
241
- (@with_collection_parameter || name.demodulize.underscore.chomp("_component")).to_sym
302
+ # Ensure the component initializer accepts the
303
+ # collection parameter. By default, we do not
304
+ # validate that the default parameter name
305
+ # is accepted, as support for collection
306
+ # rendering is optional.
307
+ def validate_collection_parameter!(validate_default: false)
308
+ parameter = validate_default ? collection_parameter : provided_collection_parameter
309
+
310
+ return unless parameter
311
+ return if instance_method(:initialize).parameters.map(&:last).include?(parameter)
312
+
313
+ raise ArgumentError.new(
314
+ "#{self} initializer must accept " \
315
+ "`#{parameter}` collection parameter."
316
+ )
242
317
  end
243
318
 
244
319
  private
245
320
 
321
+ def provided_collection_parameter
322
+ @provided_collection_parameter ||= nil
323
+ end
324
+
246
325
  def compiled_template(file_path)
247
326
  handler = ActionView::Template.handler_for_extension(File.extname(file_path).gsub(".", ""))
248
327
  template = File.read(file_path)
@@ -272,7 +351,22 @@ module ViewComponent
272
351
 
273
352
  def matching_views_in_source_location
274
353
  return [] unless source_location
275
- (Dir["#{source_location.chomp(File.extname(source_location))}.*{#{ActionView::Template.template_handler_extensions.join(',')}}"] - [source_location])
354
+
355
+ location_without_extension = source_location.chomp(File.extname(source_location))
356
+
357
+ extenstions = ActionView::Template.template_handler_extensions.join(",")
358
+
359
+ # view files in the same directory as te component
360
+ sidecar_files = Dir["#{location_without_extension}.*{#{extenstions}}"]
361
+
362
+ # view files in a directory named like the component
363
+ directory = File.dirname(source_location)
364
+ filename = File.basename(source_location, ".rb")
365
+ component_name = name.demodulize.underscore
366
+
367
+ sidecar_directory_files = Dir["#{directory}/#{component_name}/#{filename}.*{#{extenstions}}"]
368
+
369
+ (sidecar_files - [source_location] + sidecar_directory_files)
276
370
  end
277
371
 
278
372
  def templates
@@ -1,13 +1,17 @@
1
-
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module ViewComponent
5
4
  class Collection
6
5
  def render_in(view_context, &block)
7
- as = @component.collection_parameter_name
6
+ iterator = ActionView::PartialIteration.new(@collection.size)
7
+
8
+ @component.compile(raise_errors: true)
9
+ @component.validate_collection_parameter!(validate_default: true)
8
10
 
9
11
  @collection.map do |item|
10
- @component.new(@options.merge(as => item)).render_in(view_context, &block)
12
+ content = @component.new(component_options(item, iterator)).render_in(view_context, &block)
13
+ iterator.iterate!
14
+ content
11
15
  end.join.html_safe
12
16
  end
13
17
 
@@ -26,5 +30,12 @@ module ViewComponent
26
30
  raise ArgumentError.new("The value of the argument isn't a valid collection. Make sure it responds to to_ary: #{object.inspect}")
27
31
  end
28
32
  end
33
+
34
+ def component_options(item, iterator)
35
+ item_options = { @component.collection_parameter => item }
36
+ item_options[@component.collection_counter_parameter] = iterator.index + 1 if @component.counter_argument_present?
37
+
38
+ @options.merge(item_options)
39
+ end
29
40
  end
30
41
  end
@@ -11,6 +11,7 @@ module ViewComponent
11
11
  options = app.config.view_component
12
12
 
13
13
  options.show_previews = Rails.env.development? if options.show_previews.nil?
14
+ options.preview_route ||= ViewComponent::Base.preview_route
14
15
 
15
16
  if options.show_previews
16
17
  options.preview_path ||= defined?(Rails.root) ? "#{Rails.root}/test/components/previews" : nil
@@ -64,8 +65,8 @@ module ViewComponent
64
65
 
65
66
  if options.show_previews
66
67
  app.routes.prepend do
67
- get "/rails/view_components" => "rails/view_components#index", :internal => true
68
- get "/rails/view_components/*path" => "rails/view_components#previews", :internal => true
68
+ get options.preview_route, to: "view_components#index", as: :preview_view_components, internal: true
69
+ get "#{options.preview_route}/*path", to: "view_components#previews", as: :preview_view_component, internal: true
69
70
  end
70
71
  end
71
72
  end
@@ -19,8 +19,12 @@ module ViewComponent # :nodoc:
19
19
  end
20
20
 
21
21
  # Returns the arguments for rendering of the component in its layout
22
- def render_args(example)
23
- new.public_send(example).merge(layout: @layout)
22
+ def render_args(example, params: {})
23
+ example_params_names = instance_method(example).parameters.map(&:last)
24
+ provided_params = params.slice(*example_params_names).to_h.symbolize_keys
25
+ result = provided_params.empty? ? new.public_send(example) : new.public_send(example, **provided_params)
26
+ @layout = nil unless defined?(@layout)
27
+ result.merge(layout: @layout)
24
28
  end
25
29
 
26
30
  # Returns the component object class associated to the preview.
@@ -20,6 +20,16 @@ module ViewComponent # :nodoc:
20
20
  # Defaults to +true+ for development environment
21
21
  #
22
22
  mattr_accessor :show_previews, instance_writer: false
23
+
24
+ # Set the entry route for component previews through app configuration:
25
+ #
26
+ # config.view_component.preview_route = "/previews"
27
+ #
28
+ # Defaults to +/rails/view_components+ when `show_previews' is enabled
29
+ #
30
+ mattr_accessor :preview_route, instance_writer: false do
31
+ "/rails/view_components"
32
+ end
23
33
  end
24
34
  end
25
35
  end
@@ -7,7 +7,7 @@ module ViewComponent
7
7
  include Capybara::Minitest::Assertions
8
8
 
9
9
  def page
10
- Capybara::Node::Simple.new(@raw)
10
+ Capybara::Node::Simple.new(@rendered_component)
11
11
  end
12
12
 
13
13
  def refute_component_rendered
@@ -17,10 +17,12 @@ module ViewComponent
17
17
  warn "WARNING in `ViewComponent::TestHelpers`: You must add `capybara` to your Gemfile to use Capybara assertions."
18
18
  end
19
19
 
20
+ attr_reader :rendered_component
21
+
20
22
  def render_inline(component, **args, &block)
21
- @raw = controller.view_context.render(component, args, &block)
23
+ @rendered_component = controller.view_context.render(component, args, &block)
22
24
 
23
- Nokogiri::HTML.fragment(@raw)
25
+ Nokogiri::HTML.fragment(@rendered_component)
24
26
  end
25
27
 
26
28
  def controller