view_component 2.20.0 → 2.23.1

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.

@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class <%= class_name %>Component < <%= parent_class %>
2
4
  <%- if initialize_signature -%>
3
5
  def initialize(<%= initialize_signature %>)
@@ -6,6 +6,7 @@ require "view_component/collection"
6
6
  require "view_component/compile_cache"
7
7
  require "view_component/previewable"
8
8
  require "view_component/slotable"
9
+ require "view_component/slotable_v2"
9
10
 
10
11
  module ViewComponent
11
12
  class Base < ActionView::Base
@@ -20,10 +21,6 @@ module ViewComponent
20
21
  class_attribute :content_areas
21
22
  self.content_areas = [] # class_attribute:default doesn't work until Rails 5.2
22
23
 
23
- # Hash of registered Slots
24
- class_attribute :slots
25
- self.slots = {}
26
-
27
24
  # Entrypoint for rendering components.
28
25
  #
29
26
  # view_context: ActionView context from calling view
@@ -64,7 +61,7 @@ module ViewComponent
64
61
  @virtual_path ||= virtual_path
65
62
 
66
63
  # For template variants (+phone, +desktop, etc.)
67
- @variant = @lookup_context.variants.first
64
+ @variant ||= @lookup_context.variants.first
68
65
 
69
66
  # For caching, such as #cache_if
70
67
  @current_template = nil unless defined?(@current_template)
@@ -99,13 +96,16 @@ module ViewComponent
99
96
 
100
97
  def initialize(*); end
101
98
 
102
- # If trying to render a partial or template inside a component,
103
- # pass the render call to the parent view_context.
99
+ # Re-use original view_context if we're not rendering a component.
100
+ #
101
+ # This prevents an exception when rendering a partial inside of a component that has also been rendered outside
102
+ # of the component. This is due to the partials compiled template method existing in the parent `view_context`,
103
+ # and not the component's `view_context`.
104
104
  def render(options = {}, args = {}, &block)
105
- if options.is_a?(String) || (options.is_a?(Hash) && options.has_key?(:partial))
106
- view_context.render(options, args, &block)
107
- else
105
+ if options.is_a? ViewComponent::Base
108
106
  super
107
+ else
108
+ view_context.render(options, args, &block)
109
109
  end
110
110
  end
111
111
 
@@ -132,7 +132,10 @@ module ViewComponent
132
132
 
133
133
  # For caching, such as #cache_if
134
134
  def format
135
- @variant
135
+ # Ruby 2.6 throws a warning without checking `defined?`, 2.7 does not
136
+ if defined?(@variant)
137
+ @variant
138
+ end
136
139
  end
137
140
 
138
141
  # Assign the provided content to the content area accessor
@@ -149,6 +152,12 @@ module ViewComponent
149
152
  nil
150
153
  end
151
154
 
155
+ def with_variant(variant)
156
+ @variant = variant
157
+
158
+ self
159
+ end
160
+
152
161
  private
153
162
 
154
163
  # Exposes the current request to the component.
@@ -201,10 +210,6 @@ module ViewComponent
201
210
  # Removes the first part of the path and the extension.
202
211
  child.virtual_path = child.source_location.gsub(%r{(.*app/components)|(\.rb)}, "")
203
212
 
204
- # Clone slot configuration into child class
205
- # see #test_slots_pollution
206
- child.slots = self.slots.clone
207
-
208
213
  super
209
214
  end
210
215
 
@@ -285,7 +290,6 @@ module ViewComponent
285
290
  def provided_collection_parameter
286
291
  @provided_collection_parameter ||= nil
287
292
  end
288
-
289
293
  end
290
294
 
291
295
  ActiveSupport.run_load_hooks(:view_component, self)
@@ -146,21 +146,36 @@ module ViewComponent
146
146
  source_location = component_class.source_location
147
147
  return [] unless source_location
148
148
 
149
- location_without_extension = source_location.chomp(File.extname(source_location))
150
-
151
149
  extensions = ActionView::Template.template_handler_extensions.join(",")
152
150
 
153
- # view files in the same directory as the component
154
- sidecar_files = Dir["#{location_without_extension}.*{#{extensions}}"]
155
-
156
151
  # view files in a directory named like the component
157
152
  directory = File.dirname(source_location)
158
153
  filename = File.basename(source_location, ".rb")
159
154
  component_name = component_class.name.demodulize.underscore
160
155
 
156
+ # Add support for nested components defined in the same file.
157
+ #
158
+ # e.g.
159
+ #
160
+ # class MyComponent < ViewComponent::Base
161
+ # class MyOtherComponent < ViewComponent::Base
162
+ # end
163
+ # end
164
+ #
165
+ # Without this, `MyOtherComponent` will not look for `my_component/my_other_component.html.erb`
166
+ nested_component_files = if component_class.name.include?("::")
167
+ nested_component_path = component_class.name.deconstantize.underscore
168
+ Dir["#{directory}/#{nested_component_path}/#{component_name}.*{#{extensions}}"]
169
+ else
170
+ []
171
+ end
172
+
173
+ # view files in the same directory as the component
174
+ sidecar_files = Dir["#{directory}/#{component_name}.*{#{extensions}}"]
175
+
161
176
  sidecar_directory_files = Dir["#{directory}/#{component_name}/#{filename}.*{#{extensions}}"]
162
177
 
163
- (sidecar_files - [source_location] + sidecar_directory_files)
178
+ (sidecar_files - [source_location] + sidecar_directory_files + nested_component_files)
164
179
  end
165
180
 
166
181
  def inline_calls
@@ -44,7 +44,7 @@ module ViewComponent
44
44
 
45
45
  initializer "view_component.eager_load_actions" do
46
46
  ActiveSupport.on_load(:after_initialize) do
47
- ViewComponent::Base.descendants.each(&:compile)
47
+ ViewComponent::Base.descendants.each(&:compile) if Rails.application.config.eager_load
48
48
  end
49
49
  end
50
50
 
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ class SlotV2
5
+ attr_writer :_component_instance, :_content_block, :_content
6
+
7
+ def initialize(parent)
8
+ @parent = parent
9
+ end
10
+
11
+ # Used to render the slot content in the template
12
+ #
13
+ # There's currently 3 different values that may be set, that we can render.
14
+ #
15
+ # If the slot renderable is a component, the string class name of a
16
+ # component, or a function that returns a component, we render that
17
+ # component instance, returning the string.
18
+ #
19
+ # If the slot renderable is a function and returns a string, it is
20
+ # set as `@_content` and is returned directly.
21
+ #
22
+ # If there is no slot renderable, we evaluate the block passed to
23
+ # the slot and return it.
24
+ def to_s
25
+ view_context = @parent.send(:view_context)
26
+ view_context.capture do
27
+ if defined?(@_component_instance)
28
+ # render_in is faster than `parent.render`
29
+ @_component_instance.render_in(view_context, &@_content_block)
30
+ elsif defined?(@_content)
31
+ @_content
32
+ elsif defined?(@_content_block)
33
+ @_content_block.call
34
+ end
35
+ end
36
+ end
37
+
38
+ # Allow access to public component methods via the wrapper
39
+ #
40
+ # e.g.
41
+ #
42
+ # calling `header.name` (where `header` is a slot) will call `name`
43
+ # on the `HeaderComponent` instance.
44
+ #
45
+ # Where the component may look like:
46
+ #
47
+ # class MyComponent < ViewComponent::Base
48
+ # has_one :header, HeaderComponent
49
+ #
50
+ # class HeaderComponent < ViewComponent::Base
51
+ # def name
52
+ # @name
53
+ # end
54
+ # end
55
+ # end
56
+ #
57
+ def method_missing(symbol, *args, &block)
58
+ @_component_instance.public_send(symbol, *args, &block)
59
+ end
60
+
61
+ def respond_to_missing?(symbol, include_all = false)
62
+ defined?(@_component_instance) && @_component_instance.respond_to?(symbol, include_all)
63
+ end
64
+ end
65
+ end
@@ -8,6 +8,12 @@ module ViewComponent
8
8
  module Slotable
9
9
  extend ActiveSupport::Concern
10
10
 
11
+ included do
12
+ # Hash of registered Slots
13
+ class_attribute :slots
14
+ self.slots = {}
15
+ end
16
+
11
17
  class_methods do
12
18
  # support initializing slots as:
13
19
  #
@@ -63,6 +69,14 @@ module ViewComponent
63
69
  }
64
70
  end
65
71
  end
72
+
73
+ def inherited(child)
74
+ # Clone slot configuration into child class
75
+ # see #test_slots_pollution
76
+ child.slots = self.slots.clone
77
+
78
+ super
79
+ end
66
80
  end
67
81
 
68
82
  # Build a Slot instance on a component,
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ require "view_component/slot_v2"
6
+
7
+ module ViewComponent
8
+ module SlotableV2
9
+ extend ActiveSupport::Concern
10
+
11
+ # Setup component slot state
12
+ included do
13
+ # Hash of registered Slots
14
+ class_attribute :registered_slots
15
+ self.registered_slots = {}
16
+ end
17
+
18
+ class_methods do
19
+ ##
20
+ # Registers a sub-component
21
+ #
22
+ # = Example
23
+ #
24
+ # renders_one :header -> (classes:) do
25
+ # HeaderComponent.new(classes: classes)
26
+ # end
27
+ #
28
+ # # OR
29
+ #
30
+ # renders_one :header, HeaderComponent
31
+ #
32
+ # where `HeaderComponent` is defined as:
33
+ #
34
+ # class HeaderComponent < ViewComponent::Base
35
+ # def initialize(classes:)
36
+ # @classes = classes
37
+ # end
38
+ # end
39
+ #
40
+ # and has the following template:
41
+ #
42
+ # <header class="<%= @classes %>">
43
+ # <%= content %>
44
+ # </header>
45
+ #
46
+ # = Rendering sub-component content
47
+ #
48
+ # The component's sidecar template can access the sub-component by calling a
49
+ # helper method with the same name as the sub-component.
50
+ #
51
+ # <h1>
52
+ # <%= header do %>
53
+ # My header title
54
+ # <% end %>
55
+ # </h1>
56
+ #
57
+ # = Setting sub-component content
58
+ #
59
+ # Consumers of the component can render a sub-component by calling a
60
+ # helper method with the same name as the slot.
61
+ #
62
+ # <%= render_inline(MyComponent.new) do |component| %>
63
+ # <%= component.header(classes: "Foo") do %>
64
+ # <p>Bar</p>
65
+ # <% end %>
66
+ # <% end %>
67
+ def renders_one(slot_name, callable = nil)
68
+ validate_slot_name(slot_name)
69
+
70
+ define_method slot_name do |*args, **kwargs, &block|
71
+ if args.empty? && kwargs.empty? && block.nil?
72
+ get_slot(slot_name)
73
+ else
74
+ set_slot(slot_name, *args, **kwargs, &block)
75
+ end
76
+ end
77
+
78
+ register_slot(slot_name, collection: false, callable: callable)
79
+ end
80
+
81
+ ##
82
+ # Registers a collection sub-component
83
+ #
84
+ # = Example
85
+ #
86
+ # render_many :items, -> (name:) { ItemComponent.new(name: name }
87
+ #
88
+ # # OR
89
+ #
90
+ # render_many :items, ItemComponent
91
+ #
92
+ # = Rendering sub-components
93
+ #
94
+ # The component's sidecar template can access the slot by calling a
95
+ # helper method with the same name as the slot.
96
+ #
97
+ # <h1>
98
+ # <%= items.each do |item| %>
99
+ # <%= item %>
100
+ # <% end %>
101
+ # </h1>
102
+ #
103
+ # = Setting sub-component content
104
+ #
105
+ # Consumers of the component can set the content of a slot by calling a
106
+ # helper method with the same name as the slot. The method can be
107
+ # called multiple times to append to the slot.
108
+ #
109
+ # <%= render_inline(MyComponent.new) do |component| %>
110
+ # <%= component.item(name: "Foo") do %>
111
+ # <p>One</p>
112
+ # <% end %>
113
+ #
114
+ # <%= component.item(name: "Bar") do %>
115
+ # <p>two</p>
116
+ # <% end %>
117
+ # <% end %>
118
+ def renders_many(slot_name, callable = nil)
119
+ validate_slot_name(slot_name)
120
+
121
+ singular_name = ActiveSupport::Inflector.singularize(slot_name)
122
+
123
+ # Define setter for singular names
124
+ # e.g. `renders_many :items` allows fetching all tabs with
125
+ # `component.tabs` and setting a tab with `component.tab`
126
+ define_method singular_name do |*args, **kwargs, &block|
127
+ set_slot(slot_name, *args, **kwargs, &block)
128
+ end
129
+
130
+ # Instantiates and and adds multiple slots forwarding the first
131
+ # argument to each slot constructor
132
+ define_method slot_name do |collection_args = nil, &block|
133
+ if collection_args.nil? && block.nil?
134
+ get_slot(slot_name)
135
+ else
136
+ collection_args.each do |args|
137
+ set_slot(slot_name, **args, &block)
138
+ end
139
+ end
140
+ end
141
+
142
+ register_slot(slot_name, collection: true, callable: callable)
143
+ end
144
+
145
+ # Clone slot configuration into child class
146
+ # see #test_slots_pollution
147
+ def inherited(child)
148
+ child.registered_slots = self.registered_slots.clone
149
+ super
150
+ end
151
+
152
+ private
153
+
154
+ def register_slot(slot_name, collection:, callable:)
155
+ # Setup basic slot data
156
+ slot = {
157
+ collection: collection,
158
+ }
159
+ # If callable responds to `render_in`, we set it on the slot as a renderable
160
+ if callable && callable.respond_to?(:method_defined?) && callable.method_defined?(:render_in)
161
+ slot[:renderable] = callable
162
+ elsif callable.is_a?(String)
163
+ # If callable is a string, we assume it's referencing an internal class
164
+ slot[:renderable_class_name] = callable
165
+ elsif callable
166
+ # If slot does not respond to `render_in`, we assume it's a proc,
167
+ # define a method, and save a reference to it to call when setting
168
+ method_name = :"_call_#{slot_name}"
169
+ define_method method_name, &callable
170
+ slot[:renderable_function] = instance_method(method_name)
171
+ end
172
+
173
+ # Register the slot on the component
174
+ self.registered_slots[slot_name] = slot
175
+ end
176
+
177
+ def validate_slot_name(slot_name)
178
+ if self.registered_slots.key?(slot_name)
179
+ # TODO remove? This breaks overriding slots when slots are inherited
180
+ raise ArgumentError.new("#{slot_name} slot declared multiple times")
181
+ end
182
+ end
183
+ end
184
+
185
+ def get_slot(slot_name)
186
+ slot = self.class.registered_slots[slot_name]
187
+ @_set_slots ||= {}
188
+
189
+ if @_set_slots[slot_name]
190
+ return @_set_slots[slot_name]
191
+ end
192
+
193
+ if slot[:collection]
194
+ []
195
+ else
196
+ nil
197
+ end
198
+ end
199
+
200
+ def set_slot(slot_name, *args, **kwargs, &block)
201
+ slot_definition = self.class.registered_slots[slot_name]
202
+
203
+ slot = SlotV2.new(self)
204
+
205
+ # Passing the block to the sub-component wrapper like this has two
206
+ # benefits:
207
+ #
208
+ # 1. If this is a `content_area` style sub-component, we will render the
209
+ # block via the `slot`
210
+ #
211
+ # 2. Since we have to pass block content to components when calling
212
+ # `render`, evaluating the block here would require us to call
213
+ # `view_context.capture` twice, which is slower
214
+ slot._content_block = block if block_given?
215
+
216
+ # If class
217
+ if slot_definition[:renderable]
218
+ slot._component_instance = slot_definition[:renderable].new(*args, **kwargs)
219
+ # If class name as a string
220
+ elsif slot_definition[:renderable_class_name]
221
+ slot._component_instance = self.class.const_get(slot_definition[:renderable_class_name]).new(*args, **kwargs)
222
+ # If passed a lambda
223
+ elsif slot_definition[:renderable_function]
224
+ # Use `bind(self)` to ensure lambda is executed in the context of the
225
+ # current component. This is necessary to allow the lambda to access helper
226
+ # methods like `content_tag` as well as parent component state.
227
+ renderable_value = slot_definition[:renderable_function].bind(self).call(*args, **kwargs, &block)
228
+
229
+ # Function calls can return components, so if it's a component handle it specially
230
+ if renderable_value.respond_to?(:render_in)
231
+ slot._component_instance = renderable_value
232
+ else
233
+ slot._content = renderable_value
234
+ end
235
+ end
236
+
237
+ @_set_slots ||= {}
238
+
239
+ if slot_definition[:collection]
240
+ @_set_slots[slot_name] ||= []
241
+ @_set_slots[slot_name].push(slot)
242
+ else
243
+ @_set_slots[slot_name] = slot
244
+ end
245
+
246
+ nil
247
+ end
248
+ end
249
+ end