view_component 2.5.1 → 2.10.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
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,51 @@ 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 initialize_parameters.map(&:last).include?(parameter)
311
+
312
+ # If Ruby cannot parse the component class, then the initalize
313
+ # parameters will be empty and ViewComponent will not be able to render
314
+ # the component.
315
+ if initialize_parameters.empty?
316
+ raise ArgumentError.new(
317
+ "#{self} initializer is empty or invalid."
318
+ )
319
+ end
320
+
321
+ raise ArgumentError.new(
322
+ "#{self} initializer must accept " \
323
+ "`#{parameter}` collection parameter."
324
+ )
250
325
  end
251
326
 
252
327
  private
253
328
 
329
+ def initialize_parameters
330
+ instance_method(:initialize).parameters
331
+ end
332
+
333
+ def provided_collection_parameter
334
+ @provided_collection_parameter ||= nil
335
+ end
336
+
254
337
  def compiled_template(file_path)
255
338
  handler = ActionView::Template.handler_for_extension(File.extname(file_path).gsub(".", ""))
256
339
  template = File.read(file_path)
@@ -280,7 +363,22 @@ module ViewComponent
280
363
 
281
364
  def matching_views_in_source_location
282
365
  return [] unless source_location
283
- (Dir["#{source_location.chomp(File.extname(source_location))}.*{#{ActionView::Template.template_handler_extensions.join(',')}}"] - [source_location])
366
+
367
+ location_without_extension = source_location.chomp(File.extname(source_location))
368
+
369
+ extenstions = ActionView::Template.template_handler_extensions.join(",")
370
+
371
+ # view files in the same directory as te component
372
+ sidecar_files = Dir["#{location_without_extension}.*{#{extenstions}}"]
373
+
374
+ # view files in a directory named like the component
375
+ directory = File.dirname(source_location)
376
+ filename = File.basename(source_location, ".rb")
377
+ component_name = name.demodulize.underscore
378
+
379
+ sidecar_directory_files = Dir["#{directory}/#{component_name}/#{filename}.*{#{extenstions}}"]
380
+
381
+ (sidecar_files - [source_location] + sidecar_directory_files)
284
382
  end
285
383
 
286
384
  def templates
@@ -5,7 +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!
8
+ @component.compile(raise_errors: true)
9
+ @component.validate_collection_parameter!(validate_default: true)
10
+
9
11
  @collection.map do |item|
10
12
  content = @component.new(component_options(item, iterator)).render_in(view_context, &block)
11
13
  iterator.iterate!
@@ -30,8 +32,8 @@ module ViewComponent
30
32
  end
31
33
 
32
34
  def component_options(item, iterator)
33
- item_options = { @component.collection_parameter_name => item }
34
- 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?
35
37
 
36
38
  @options.merge(item_options)
37
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