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

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