view_component 2.5.0 → 2.9.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
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>
@@ -3,6 +3,7 @@
3
3
  require "action_view"
4
4
  require "active_support/configurable"
5
5
  require "view_component/collection"
6
+ require "view_component/compile_cache"
6
7
  require "view_component/previewable"
7
8
 
8
9
  module ViewComponent
@@ -10,15 +11,11 @@ module ViewComponent
10
11
  include ActiveSupport::Configurable
11
12
  include ViewComponent::Previewable
12
13
 
14
+ # For CSRF authenticity tokens in forms
13
15
  delegate :form_authenticity_token, :protect_against_forgery?, to: :helpers
14
16
 
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
17
+ class_attribute :content_areas
18
+ self.content_areas = [] # class_attribute:default doesn't work until Rails 5.2
22
19
 
23
20
  # Entrypoint for rendering components.
24
21
  #
@@ -45,20 +42,32 @@ module ViewComponent
45
42
  # <span title="greeting">Hello, world!</span>
46
43
  #
47
44
  def render_in(view_context, &block)
48
- self.class.compile!
45
+ self.class.compile(raise_errors: true)
46
+
49
47
  @view_context = view_context
50
- @view_renderer ||= view_context.view_renderer
51
48
  @lookup_context ||= view_context.lookup_context
49
+
50
+ # required for path helpers in older Rails versions
51
+ @view_renderer ||= view_context.view_renderer
52
+
53
+ # For content_for
52
54
  @view_flow ||= view_context.view_flow
55
+
56
+ # For i18n
53
57
  @virtual_path ||= virtual_path
58
+
59
+ # For template variants (+phone, +desktop, etc.)
54
60
  @variant = @lookup_context.variants.first
55
61
 
62
+ # For caching, such as #cache_if
63
+ @current_template = nil unless defined?(@current_template)
56
64
  old_current_template = @current_template
57
65
  @current_template = self
58
66
 
67
+ # Assign captured content passed to component as a block to @content
59
68
  @content = view_context.capture(self, &block) if block_given?
60
69
 
61
- before_render_check
70
+ before_render
62
71
 
63
72
  if render?
64
73
  send(self.class.call_method_name(@variant))
@@ -69,6 +78,10 @@ module ViewComponent
69
78
  @current_template = old_current_template
70
79
  end
71
80
 
81
+ def before_render
82
+ before_render_check
83
+ end
84
+
72
85
  def before_render_check
73
86
  # noop
74
87
  end
@@ -77,12 +90,10 @@ module ViewComponent
77
90
  true
78
91
  end
79
92
 
80
- def self.short_identifier
81
- @short_identifier ||= defined?(Rails.root) ? source_location.sub("#{Rails.root}/", "") : source_location
82
- end
83
-
84
93
  def initialize(*); end
85
94
 
95
+ # If trying to render a partial or template inside a component,
96
+ # pass the render call to the parent view_context.
86
97
  def render(options = {}, args = {}, &block)
87
98
  if options.is_a?(String) || (options.is_a?(Hash) && options.has_key?(:partial))
88
99
  view_context.render(options, args, &block)
@@ -95,7 +106,7 @@ module ViewComponent
95
106
  @controller ||= view_context.controller
96
107
  end
97
108
 
98
- # Provides a proxy to access helper methods through
109
+ # Provides a proxy to access helper methods
99
110
  def helpers
100
111
  @helpers ||= view_context
101
112
  end
@@ -105,14 +116,17 @@ module ViewComponent
105
116
  self.class.source_location.gsub(%r{(.*app/components)|(\.rb)}, "")
106
117
  end
107
118
 
119
+ # For caching, such as #cache_if
108
120
  def view_cache_dependencies
109
121
  []
110
122
  end
111
123
 
112
- def format # :nodoc:
124
+ # For caching, such as #cache_if
125
+ def format
113
126
  @variant
114
127
  end
115
128
 
129
+ # Assign the provided content to the content area accessor
116
130
  def with(area, content = nil, &block)
117
131
  unless content_areas.include?(area)
118
132
  raise ArgumentError.new "Unknown content_area '#{area}' - expected one of '#{content_areas}'"
@@ -128,6 +142,9 @@ module ViewComponent
128
142
 
129
143
  private
130
144
 
145
+ # Exposes the current request to the component.
146
+ # Use sparingly as doing so introduces coupling
147
+ # that inhibits encapsulation & reuse.
131
148
  def request
132
149
  @request ||= controller.request
133
150
  end
@@ -143,7 +160,18 @@ module ViewComponent
143
160
  class << self
144
161
  attr_accessor :source_location
145
162
 
163
+ # Render a component collection.
164
+ def with_collection(*args)
165
+ Collection.new(self, *args)
166
+ end
167
+
168
+ # Provide identifier for ActionView template annotations
169
+ def short_identifier
170
+ @short_identifier ||= defined?(Rails.root) ? source_location.sub("#{Rails.root}/", "") : source_location
171
+ end
172
+
146
173
  def inherited(child)
174
+ # If we're in Rails, add application url_helpers to the component context
147
175
  if defined?(Rails)
148
176
  child.include Rails.application.routes.url_helpers unless child < Rails.application.routes.url_helpers
149
177
  end
@@ -165,58 +193,83 @@ module ViewComponent
165
193
  end
166
194
 
167
195
  def compiled?
168
- @compiled && ActionView::Base.cache_template_loading
169
- end
170
-
171
- def compile!
172
- compile(raise_template_errors: true)
196
+ CompileCache.compiled?(self)
173
197
  end
174
198
 
175
199
  # 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)
200
+ #
201
+ # Do as much work as possible in this step, as doing so reduces the amount
202
+ # of work done each time a component is rendered.
203
+ def compile(raise_errors: false)
179
204
  return if compiled?
180
205
 
181
206
  if template_errors.present?
182
- raise ViewComponent::TemplateError.new(template_errors) if raise_template_errors
207
+ raise ViewComponent::TemplateError.new(template_errors) if raise_errors
183
208
  return false
184
209
  end
185
210
 
211
+ if instance_methods(false).include?(:before_render_check)
212
+ ActiveSupport::Deprecation.warn(
213
+ "`before_render_check` will be removed in v3.0.0. Use `before_render` instead."
214
+ )
215
+ end
216
+
217
+ # Remove any existing singleton methods,
218
+ # as Ruby warns when redefining a method.
219
+ remove_possible_singleton_method(:variants)
220
+ remove_possible_singleton_method(:collection_parameter)
221
+ remove_possible_singleton_method(:collection_counter_parameter)
222
+ remove_possible_singleton_method(:counter_argument_present?)
223
+
186
224
  define_singleton_method(:variants) do
187
225
  templates.map { |template| template[:variant] } + variants_from_inline_calls(inline_calls)
188
226
  end
189
227
 
190
- define_singleton_method(:collection_counter_parameter_name) do
191
- "#{collection_parameter_name}_counter".to_sym
228
+ define_singleton_method(:collection_parameter) do
229
+ if provided_collection_parameter
230
+ provided_collection_parameter
231
+ else
232
+ name.demodulize.underscore.chomp("_component").to_sym
233
+ end
234
+ end
235
+
236
+ define_singleton_method(:collection_counter_parameter) do
237
+ "#{collection_parameter}_counter".to_sym
192
238
  end
193
239
 
194
240
  define_singleton_method(:counter_argument_present?) do
195
- instance_method(:initialize).parameters.map(&:second).include?(collection_counter_parameter_name)
241
+ instance_method(:initialize).parameters.map(&:second).include?(collection_counter_parameter)
196
242
  end
197
243
 
244
+ validate_collection_parameter! if raise_errors
245
+
198
246
  # If template name annotations are turned on, a line is dynamically
199
247
  # added with a comment. In this case, we want to return a different
200
248
  # starting line number so errors that are raised will point to the
201
249
  # correct line in the component template.
202
250
  line_number =
203
- if ActionView::Base.respond_to?(:annotate_template_file_names) &&
204
- ActionView::Base.annotate_template_file_names
251
+ if ActionView::Base.respond_to?(:annotate_rendered_view_with_filenames) &&
252
+ ActionView::Base.annotate_rendered_view_with_filenames
205
253
  -2
206
254
  else
207
255
  -1
208
256
  end
209
257
 
210
258
  templates.each do |template|
259
+ # Remove existing compiled template methods,
260
+ # as Ruby warns when redefining a method.
261
+ method_name = call_method_name(template[:variant])
262
+ undef_method(method_name.to_sym) if instance_methods.include?(method_name.to_sym)
263
+
211
264
  class_eval <<-RUBY, template[:path], line_number
212
- def #{call_method_name(template[:variant])}
265
+ def #{method_name}
213
266
  @output_buffer = ActionView::OutputBuffer.new
214
267
  #{compiled_template(template[:path])}
215
268
  end
216
269
  RUBY
217
270
  end
218
271
 
219
- @compiled = true
272
+ CompileCache.register self
220
273
  end
221
274
 
222
275
  # we'll eventually want to update this to support other types
@@ -236,21 +289,38 @@ module ViewComponent
236
289
  if areas.include?(:content)
237
290
  raise ArgumentError.new ":content is a reserved content area name. Please use another name, such as ':body'"
238
291
  end
239
- attr_reader *areas
292
+ attr_reader(*areas)
240
293
  self.content_areas = areas
241
294
  end
242
295
 
243
- # Support overriding this component's collection parameter name
296
+ # Support overriding collection parameter name
244
297
  def with_collection_parameter(param)
245
- @with_collection_parameter = param
298
+ @provided_collection_parameter = param
246
299
  end
247
300
 
248
- def collection_parameter_name
249
- (@with_collection_parameter || name.demodulize.underscore.chomp("_component")).to_sym
301
+ # Ensure the component initializer accepts the
302
+ # collection parameter. By default, we do not
303
+ # validate that the default parameter name
304
+ # is accepted, as support for collection
305
+ # rendering is optional.
306
+ def validate_collection_parameter!(validate_default: false)
307
+ parameter = validate_default ? collection_parameter : provided_collection_parameter
308
+
309
+ return unless parameter
310
+ return if instance_method(:initialize).parameters.map(&:last).include?(parameter)
311
+
312
+ raise ArgumentError.new(
313
+ "#{self} initializer must accept " \
314
+ "`#{parameter}` collection parameter."
315
+ )
250
316
  end
251
317
 
252
318
  private
253
319
 
320
+ def provided_collection_parameter
321
+ @provided_collection_parameter ||= nil
322
+ end
323
+
254
324
  def compiled_template(file_path)
255
325
  handler = ActionView::Template.handler_for_extension(File.extname(file_path).gsub(".", ""))
256
326
  template = File.read(file_path)
@@ -280,7 +350,22 @@ module ViewComponent
280
350
 
281
351
  def matching_views_in_source_location
282
352
  return [] unless source_location
283
- (Dir["#{source_location.chomp(File.extname(source_location))}.*{#{ActionView::Template.template_handler_extensions.join(',')}}"] - [source_location])
353
+
354
+ location_without_extension = source_location.chomp(File.extname(source_location))
355
+
356
+ extenstions = ActionView::Template.template_handler_extensions.join(",")
357
+
358
+ # view files in the same directory as te component
359
+ sidecar_files = Dir["#{location_without_extension}.*{#{extenstions}}"]
360
+
361
+ # view files in a directory named like the component
362
+ directory = File.dirname(source_location)
363
+ filename = File.basename(source_location, ".rb")
364
+ component_name = name.demodulize.underscore
365
+
366
+ sidecar_directory_files = Dir["#{directory}/#{component_name}/#{filename}.*{#{extenstions}}"]
367
+
368
+ (sidecar_files - [source_location] + sidecar_directory_files)
284
369
  end
285
370
 
286
371
  def templates
@@ -5,6 +5,9 @@ module ViewComponent
5
5
  def render_in(view_context, &block)
6
6
  iterator = ActionView::PartialIteration.new(@collection.size)
7
7
 
8
+ @component.compile(raise_errors: true)
9
+ @component.validate_collection_parameter!(validate_default: true)
10
+
8
11
  @collection.map do |item|
9
12
  content = @component.new(component_options(item, iterator)).render_in(view_context, &block)
10
13
  iterator.iterate!
@@ -29,8 +32,8 @@ module ViewComponent
29
32
  end
30
33
 
31
34
  def component_options(item, iterator)
32
- item_options = { @component.collection_parameter_name => item }
33
- item_options[@component.collection_counter_parameter_name] = iterator.index + 1 if @component.counter_argument_present?
35
+ item_options = { @component.collection_parameter => item }
36
+ item_options[@component.collection_counter_parameter] = iterator.index + 1 if @component.counter_argument_present?
34
37
 
35
38
  @options.merge(item_options)
36
39
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ # Keeps track of which templates have already been compiled
5
+ # This is not part of the public API
6
+ module CompileCache
7
+ mattr_accessor :cache, instance_reader: false, instance_accessor: false do
8
+ Set.new
9
+ end
10
+ module_function
11
+
12
+ def register(klass)
13
+ cache << klass
14
+ end
15
+
16
+ def compiled?(klass)
17
+ cache.include? klass
18
+ end
19
+
20
+ def invalidate!
21
+ cache.clear
22
+ end
23
+ end
24
+ 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,10 +65,14 @@ 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
72
+
73
+ app.executor.to_run :before do
74
+ CompileCache.invalidate! unless ActionView::Base.cache_template_loading
75
+ end
71
76
  end
72
77
  end
73
78
  end
@@ -23,6 +23,7 @@ module ViewComponent # :nodoc:
23
23
  example_params_names = instance_method(example).parameters.map(&:last)
24
24
  provided_params = params.slice(*example_params_names).to_h.symbolize_keys
25
25
  result = provided_params.empty? ? new.public_send(example) : new.public_send(example, **provided_params)
26
+ @layout = nil unless defined?(@layout)
26
27
  result.merge(layout: @layout)
27
28
  end
28
29