view_component 2.82.0 → 3.0.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.

@@ -1,7 +1,113 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "view_component/with_content_helper"
4
+
3
5
  module ViewComponent
4
6
  class Slot
5
- attr_accessor :content
7
+ include ViewComponent::WithContentHelper
8
+
9
+ attr_writer :__vc_component_instance, :__vc_content_block, :__vc_content
10
+
11
+ def initialize(parent)
12
+ @parent = parent
13
+ end
14
+
15
+ def content?
16
+ return true if defined?(@__vc_content) && @__vc_content.present?
17
+ return true if defined?(@__vc_content_set_by_with_content) && @__vc_content_set_by_with_content.present?
18
+ return true if defined?(@__vc_content_block) && @__vc_content_block.present?
19
+ return false if !__vc_component_instance?
20
+
21
+ @__vc_component_instance.content?
22
+ end
23
+
24
+ def with_content(args)
25
+ if __vc_component_instance?
26
+ @__vc_component_instance.with_content(args)
27
+ else
28
+ super
29
+ end
30
+ end
31
+
32
+ # Used to render the slot content in the template
33
+ #
34
+ # There's currently 3 different values that may be set, that we can render.
35
+ #
36
+ # If the slot renderable is a component, the string class name of a
37
+ # component, or a function that returns a component, we render that
38
+ # component instance, returning the string.
39
+ #
40
+ # If the slot renderable is a function and returns a string, it's
41
+ # set as `@__vc_content` and is returned directly.
42
+ #
43
+ # If there is no slot renderable, we evaluate the block passed to
44
+ # the slot and return it.
45
+ def to_s
46
+ return @content if defined?(@content)
47
+
48
+ view_context = @parent.send(:view_context)
49
+
50
+ if defined?(@__vc_content_block) && defined?(@__vc_content_set_by_with_content)
51
+ raise DuplicateSlotContentError.new(self.class.name)
52
+ end
53
+
54
+ @content =
55
+ if __vc_component_instance?
56
+ @__vc_component_instance.__vc_original_view_context = @parent.__vc_original_view_context
57
+
58
+ if defined?(@__vc_content_block)
59
+ # render_in is faster than `parent.render`
60
+ @__vc_component_instance.render_in(view_context, &@__vc_content_block)
61
+ else
62
+ @__vc_component_instance.render_in(view_context)
63
+ end
64
+ elsif defined?(@__vc_content)
65
+ @__vc_content
66
+ elsif defined?(@__vc_content_block)
67
+ view_context.capture(&@__vc_content_block)
68
+ elsif defined?(@__vc_content_set_by_with_content)
69
+ @__vc_content_set_by_with_content
70
+ end
71
+
72
+ @content = @content.to_s
73
+ end
74
+
75
+ # Allow access to public component methods via the wrapper
76
+ #
77
+ # for example
78
+ #
79
+ # calling `header.name` (where `header` is a slot) will call `name`
80
+ # on the `HeaderComponent` instance.
81
+ #
82
+ # Where the component may look like:
83
+ #
84
+ # class MyComponent < ViewComponent::Base
85
+ # has_one :header, HeaderComponent
86
+ #
87
+ # class HeaderComponent < ViewComponent::Base
88
+ # def name
89
+ # @name
90
+ # end
91
+ # end
92
+ # end
93
+ #
94
+ def method_missing(symbol, *args, &block)
95
+ @__vc_component_instance.public_send(symbol, *args, &block)
96
+ end
97
+ ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true)
98
+
99
+ def html_safe?
100
+ to_s.html_safe?
101
+ end
102
+
103
+ def respond_to_missing?(symbol, include_all = false)
104
+ __vc_component_instance? && @__vc_component_instance.respond_to?(symbol, include_all)
105
+ end
106
+
107
+ private
108
+
109
+ def __vc_component_instance?
110
+ defined?(@__vc_component_instance)
111
+ end
6
112
  end
7
113
  end
@@ -1,145 +1,405 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support/concern"
4
-
5
4
  require "view_component/slot"
6
5
 
7
6
  module ViewComponent
8
7
  module Slotable
9
8
  extend ActiveSupport::Concern
10
9
 
10
+ RESERVED_NAMES = {
11
+ singular: %i[content render].freeze,
12
+ plural: %i[contents renders].freeze
13
+ }.freeze
14
+
15
+ # Setup component slot state
11
16
  included do
12
17
  # Hash of registered Slots
13
- class_attribute :slots
14
- self.slots = {}
18
+ class_attribute :registered_slots
19
+ self.registered_slots = {}
15
20
  end
16
21
 
17
22
  class_methods do
18
- # support initializing slots as:
19
- #
20
- # with_slot(
21
- # :header,
22
- # collection: true|false,
23
- # class_name: "Header" # class name string, used to instantiate Slot
24
- # )
25
- def with_slot(*slot_names, collection: false, class_name: nil)
26
- ViewComponent::Deprecation.deprecation_warning(
27
- "`with_slot`", "use the new slots API (https://viewcomponent.org/guide/slots.html) instead"
28
- )
29
-
30
- slot_names.each do |slot_name|
31
- # Ensure slot_name isn't already declared
32
- if slots.key?(slot_name)
33
- raise ArgumentError.new("#{slot_name} slot declared multiple times")
23
+ ##
24
+ # Registers a sub-component
25
+ #
26
+ # = Example
27
+ #
28
+ # renders_one :header -> (classes:) do
29
+ # HeaderComponent.new(classes: classes)
30
+ # end
31
+ #
32
+ # # OR
33
+ #
34
+ # renders_one :header, HeaderComponent
35
+ #
36
+ # where `HeaderComponent` is defined as:
37
+ #
38
+ # class HeaderComponent < ViewComponent::Base
39
+ # def initialize(classes:)
40
+ # @classes = classes
41
+ # end
42
+ # end
43
+ #
44
+ # and has the following template:
45
+ #
46
+ # <header class="<%= @classes %>">
47
+ # <%= content %>
48
+ # </header>
49
+ #
50
+ # = Rendering sub-component content
51
+ #
52
+ # The component's sidecar template can access the sub-component by calling a
53
+ # helper method with the same name as the sub-component.
54
+ #
55
+ # <h1>
56
+ # <%= header do %>
57
+ # My header title
58
+ # <% end %>
59
+ # </h1>
60
+ #
61
+ # = Setting sub-component content
62
+ #
63
+ # Consumers of the component can render a sub-component by calling a
64
+ # helper method with the same name as the slot prefixed with `with_`.
65
+ #
66
+ # <%= render_inline(MyComponent.new) do |component| %>
67
+ # <% component.with_header(classes: "Foo") do %>
68
+ # <p>Bar</p>
69
+ # <% end %>
70
+ # <% end %>
71
+ #
72
+ # Additionally, content can be set by calling `with_SLOT_NAME_content`
73
+ # on the component instance.
74
+ #
75
+ # <%= render_inline(MyComponent.new.with_header_content("Foo")) %>
76
+ def renders_one(slot_name, callable = nil)
77
+ validate_singular_slot_name(slot_name)
78
+
79
+ if callable.is_a?(Hash) && callable.key?(:types)
80
+ register_polymorphic_slot(slot_name, callable[:types], collection: false)
81
+ else
82
+ validate_plural_slot_name(ActiveSupport::Inflector.pluralize(slot_name).to_sym)
83
+
84
+ setter_method_name = :"with_#{slot_name}"
85
+
86
+ define_method setter_method_name do |*args, &block|
87
+ set_slot(slot_name, nil, *args, &block)
88
+ end
89
+ ruby2_keywords(setter_method_name) if respond_to?(:ruby2_keywords, true)
90
+
91
+ define_method slot_name do
92
+ get_slot(slot_name)
34
93
  end
35
94
 
36
- # Ensure slot name isn't :content
37
- if slot_name == :content
38
- raise ArgumentError.new ":content is a reserved slot name. Please use another name, such as ':body'"
95
+ define_method "#{slot_name}?" do
96
+ get_slot(slot_name).present?
39
97
  end
40
98
 
41
- # Set the name of the method used to access the Slot(s)
42
- accessor_name =
99
+ define_method "with_#{slot_name}_content" do |content|
100
+ send(setter_method_name) { content.to_s }
101
+
102
+ self
103
+ end
104
+
105
+ register_slot(slot_name, collection: false, callable: callable)
106
+ end
107
+ end
108
+
109
+ ##
110
+ # Registers a collection sub-component
111
+ #
112
+ # = Example
113
+ #
114
+ # renders_many :items, -> (name:) { ItemComponent.new(name: name }
115
+ #
116
+ # # OR
117
+ #
118
+ # renders_many :items, ItemComponent
119
+ #
120
+ # = Rendering sub-components
121
+ #
122
+ # The component's sidecar template can access the slot by calling a
123
+ # helper method with the same name as the slot.
124
+ #
125
+ # <h1>
126
+ # <% items.each do |item| %>
127
+ # <%= item %>
128
+ # <% end %>
129
+ # </h1>
130
+ #
131
+ # = Setting sub-component content
132
+ #
133
+ # Consumers of the component can set the content of a slot by calling a
134
+ # helper method with the same name as the slot prefixed with `with_`. The
135
+ # method can be called multiple times to append to the slot.
136
+ #
137
+ # <%= render_inline(MyComponent.new) do |component| %>
138
+ # <% component.with_item(name: "Foo") do %>
139
+ # <p>One</p>
140
+ # <% end %>
141
+ #
142
+ # <% component.with_item(name: "Bar") do %>
143
+ # <p>two</p>
144
+ # <% end %>
145
+ # <% end %>
146
+ def renders_many(slot_name, callable = nil)
147
+ validate_plural_slot_name(slot_name)
148
+
149
+ if callable.is_a?(Hash) && callable.key?(:types)
150
+ register_polymorphic_slot(slot_name, callable[:types], collection: true)
151
+ else
152
+ singular_name = ActiveSupport::Inflector.singularize(slot_name)
153
+ validate_singular_slot_name(ActiveSupport::Inflector.singularize(slot_name).to_sym)
154
+
155
+ setter_method_name = :"with_#{singular_name}"
156
+
157
+ define_method setter_method_name do |*args, &block|
158
+ set_slot(slot_name, nil, *args, &block)
159
+ end
160
+ ruby2_keywords(setter_method_name) if respond_to?(:ruby2_keywords, true)
161
+
162
+ define_method "with_#{singular_name}_content" do |content|
163
+ send(setter_method_name) { content.to_s }
164
+
165
+ self
166
+ end
167
+
168
+ define_method :"with_#{slot_name}" do |collection_args = nil, &block|
169
+ collection_args.map do |args|
170
+ if args.respond_to?(:to_hash)
171
+ set_slot(slot_name, nil, **args, &block)
172
+ else
173
+ set_slot(slot_name, nil, *args, &block)
174
+ end
175
+ end
176
+ end
177
+
178
+ define_method slot_name do
179
+ get_slot(slot_name)
180
+ end
181
+
182
+ define_method "#{slot_name}?" do
183
+ get_slot(slot_name).present?
184
+ end
185
+
186
+ register_slot(slot_name, collection: true, callable: callable)
187
+ end
188
+ end
189
+
190
+ def slot_type(slot_name)
191
+ registered_slot = registered_slots[slot_name]
192
+ if registered_slot
193
+ registered_slot[:collection] ? :collection : :single
194
+ else
195
+ plural_slot_name = ActiveSupport::Inflector.pluralize(slot_name).to_sym
196
+ plural_registered_slot = registered_slots[plural_slot_name]
197
+ plural_registered_slot&.fetch(:collection) ? :collection_item : nil
198
+ end
199
+ end
200
+
201
+ # Clone slot configuration into child class
202
+ # see #test_slots_pollution
203
+ def inherited(child)
204
+ child.registered_slots = registered_slots.clone
205
+ super
206
+ end
207
+
208
+ def register_polymorphic_slot(slot_name, types, collection:)
209
+ unless types.empty?
210
+ getter_name = slot_name
211
+
212
+ define_method(getter_name) do
213
+ get_slot(slot_name)
214
+ end
215
+
216
+ define_method("#{getter_name}?") do
217
+ get_slot(slot_name).present?
218
+ end
219
+ end
220
+
221
+ renderable_hash = types.each_with_object({}) do |(poly_type, poly_callable), memo|
222
+ memo[poly_type] = define_slot(
223
+ "#{slot_name}_#{poly_type}", collection: collection, callable: poly_callable
224
+ )
225
+
226
+ setter_name =
43
227
  if collection
44
- # If Slot is a collection, set the accessor
45
- # name to the pluralized form of the slot name
46
- # For example: :tab => :tabs
47
- ActiveSupport::Inflector.pluralize(slot_name)
228
+ "#{ActiveSupport::Inflector.singularize(slot_name)}_#{poly_type}"
48
229
  else
49
- slot_name
230
+ "#{slot_name}_#{poly_type}"
50
231
  end
51
232
 
52
- instance_variable_name = "@#{accessor_name}"
233
+ setter_method_name = :"with_#{setter_name}"
53
234
 
54
- # If the slot is a collection, define an accesor that defaults to an empty array
55
- if collection
56
- class_eval <<-RUBY, __FILE__, __LINE__ + 1
57
- def #{accessor_name}
58
- content unless content_evaluated? # ensure content is loaded so slots will be defined
59
- #{instance_variable_name} ||= []
60
- end
61
- RUBY
62
- else
63
- class_eval <<-RUBY, __FILE__, __LINE__ + 1
64
- def #{accessor_name}
65
- content unless content_evaluated? # ensure content is loaded so slots will be defined
66
- #{instance_variable_name} if defined?(#{instance_variable_name})
67
- end
68
- RUBY
235
+ define_method(setter_method_name) do |*args, &block|
236
+ set_polymorphic_slot(slot_name, poly_type, *args, &block)
69
237
  end
238
+ ruby2_keywords(setter_method_name) if respond_to?(:ruby2_keywords, true)
70
239
 
71
- # Default class_name to ViewComponent::Slot
72
- class_name = "ViewComponent::Slot" unless class_name.present?
240
+ define_method "with_#{setter_name}_content" do |content|
241
+ send(setter_method_name) { content.to_s }
73
242
 
74
- # Register the slot on the component
75
- slots[slot_name] = {
76
- class_name: class_name,
77
- instance_variable_name: instance_variable_name,
78
- collection: collection
79
- }
243
+ self
244
+ end
80
245
  end
246
+
247
+ registered_slots[slot_name] = {
248
+ collection: collection,
249
+ renderable_hash: renderable_hash
250
+ }
81
251
  end
82
252
 
83
- def inherited(child)
84
- # Clone slot configuration into child class
85
- # see #test_slots_pollution
86
- child.slots = slots.clone
253
+ private
87
254
 
88
- super
255
+ def register_slot(slot_name, **kwargs)
256
+ registered_slots[slot_name] = define_slot(slot_name, **kwargs)
89
257
  end
90
- end
91
258
 
92
- # Build a Slot instance on a component,
93
- # exposing it for use inside the
94
- # component template.
95
- #
96
- # slot: Name of Slot, in symbol form
97
- # **args: Arguments to be passed to Slot initializer
98
- #
99
- # For example:
100
- # <%= render(SlotsComponent.new) do |component| %>
101
- # <% component.slot(:footer, class_names: "footer-class") do %>
102
- # <p>This is my footer!</p>
103
- # <% end %>
104
- # <% end %>
105
- #
106
- def slot(slot_name, **args, &block)
107
- # Raise ArgumentError if `slot` doesn't exist
108
- unless slots.key?(slot_name)
109
- raise ArgumentError.new "Unknown slot '#{slot_name}' - expected one of '#{slots.keys}'"
259
+ def define_slot(slot_name, collection:, callable:)
260
+ # Setup basic slot data
261
+ slot = {
262
+ collection: collection
263
+ }
264
+ return slot unless callable
265
+
266
+ # If callable responds to `render_in`, we set it on the slot as a renderable
267
+ if callable.respond_to?(:method_defined?) && callable.method_defined?(:render_in)
268
+ slot[:renderable] = callable
269
+ elsif callable.is_a?(String)
270
+ # If callable is a string, we assume it's referencing an internal class
271
+ slot[:renderable_class_name] = callable
272
+ elsif callable.respond_to?(:call)
273
+ # If slot doesn't respond to `render_in`, we assume it's a proc,
274
+ # define a method, and save a reference to it to call when setting
275
+ method_name = :"_call_#{slot_name}"
276
+ define_method method_name, &callable
277
+ slot[:renderable_function] = instance_method(method_name)
278
+ else
279
+ raise(InvalidSlotDefinitionError)
280
+ end
281
+
282
+ slot
283
+ end
284
+
285
+ def validate_plural_slot_name(slot_name)
286
+ if RESERVED_NAMES[:plural].include?(slot_name.to_sym)
287
+ raise ReservedPluralSlotNameError.new(name, slot_name)
288
+ end
289
+
290
+ raise_if_slot_ends_with_question_mark(slot_name)
291
+ raise_if_slot_registered(slot_name)
110
292
  end
111
293
 
112
- slot = slots[slot_name]
294
+ def validate_singular_slot_name(slot_name)
295
+ if slot_name.to_sym == :content
296
+ raise ContentSlotNameError.new(name)
297
+ end
298
+
299
+ if RESERVED_NAMES[:singular].include?(slot_name.to_sym)
300
+ raise ReservedSingularSlotNameError.new(name, slot_name)
301
+ end
302
+
303
+ raise_if_slot_ends_with_question_mark(slot_name)
304
+ raise_if_slot_registered(slot_name)
305
+ end
113
306
 
114
- # The class name of the Slot, such as Header
115
- slot_class = self.class.const_get(slot[:class_name])
307
+ def raise_if_slot_registered(slot_name)
308
+ if registered_slots.key?(slot_name)
309
+ # TODO remove? This breaks overriding slots when slots are inherited
310
+ raise RedefinedSlotError.new(name, slot_name)
311
+ end
312
+ end
116
313
 
117
- unless slot_class <= ViewComponent::Slot
118
- raise ArgumentError.new "#{slot[:class_name]} must inherit from ViewComponent::Slot"
314
+ def raise_if_slot_ends_with_question_mark(slot_name)
315
+ raise SlotPredicateNameError.new(name, slot_name) if slot_name.to_s.ends_with?("?")
119
316
  end
317
+ end
120
318
 
121
- # Instantiate Slot class, accommodating Slots that don't accept arguments
122
- slot_instance = args.present? ? slot_class.new(**args) : slot_class.new
319
+ def get_slot(slot_name)
320
+ content unless content_evaluated? # ensure content is loaded so slots will be defined
123
321
 
124
- # Capture block and assign to slot_instance#content
125
- slot_instance.content = view_context.capture(&block).to_s.strip.html_safe if block
322
+ slot = self.class.registered_slots[slot_name]
323
+ @__vc_set_slots ||= {}
324
+
325
+ if @__vc_set_slots[slot_name]
326
+ return @__vc_set_slots[slot_name]
327
+ end
126
328
 
127
329
  if slot[:collection]
128
- # Initialize instance variable as an empty array
129
- # if slot is a collection and has yet to be initialized
130
- unless instance_variable_defined?(slot[:instance_variable_name])
131
- instance_variable_set(slot[:instance_variable_name], [])
330
+ []
331
+ end
332
+ end
333
+
334
+ def set_slot(slot_name, slot_definition = nil, *args, &block)
335
+ slot_definition ||= self.class.registered_slots[slot_name]
336
+ slot = Slot.new(self)
337
+
338
+ # Passing the block to the sub-component wrapper like this has two
339
+ # benefits:
340
+ #
341
+ # 1. If this is a `content_area` style sub-component, we will render the
342
+ # block via the `slot`
343
+ #
344
+ # 2. Since we've to pass block content to components when calling
345
+ # `render`, evaluating the block here would require us to call
346
+ # `view_context.capture` twice, which is slower
347
+ slot.__vc_content_block = block if block
348
+
349
+ # If class
350
+ if slot_definition[:renderable]
351
+ slot.__vc_component_instance = slot_definition[:renderable].new(*args)
352
+ # If class name as a string
353
+ elsif slot_definition[:renderable_class_name]
354
+ slot.__vc_component_instance =
355
+ self.class.const_get(slot_definition[:renderable_class_name]).new(*args)
356
+ # If passed a lambda
357
+ elsif slot_definition[:renderable_function]
358
+ # Use `bind(self)` to ensure lambda is executed in the context of the
359
+ # current component. This is necessary to allow the lambda to access helper
360
+ # methods like `content_tag` as well as parent component state.
361
+ renderable_function = slot_definition[:renderable_function].bind(self)
362
+ renderable_value =
363
+ if block
364
+ renderable_function.call(*args) do |*rargs|
365
+ view_context.capture(*rargs, &block)
366
+ end
367
+ else
368
+ renderable_function.call(*args)
369
+ end
370
+
371
+ # Function calls can return components, so if it's a component handle it specially
372
+ if renderable_value.respond_to?(:render_in)
373
+ slot.__vc_component_instance = renderable_value
374
+ else
375
+ slot.__vc_content = renderable_value
132
376
  end
377
+ end
133
378
 
134
- # Append Slot instance to collection accessor Array
135
- instance_variable_get(slot[:instance_variable_name]) << slot_instance
379
+ @__vc_set_slots ||= {}
380
+
381
+ if slot_definition[:collection]
382
+ @__vc_set_slots[slot_name] ||= []
383
+ @__vc_set_slots[slot_name].push(slot)
136
384
  else
137
- # Assign the Slot instance to the slot accessor
138
- instance_variable_set(slot[:instance_variable_name], slot_instance)
385
+ @__vc_set_slots[slot_name] = slot
139
386
  end
140
387
 
141
- # Return nil, as this method shouldn't output anything to the view itself.
142
- nil
388
+ slot
389
+ end
390
+ ruby2_keywords(:set_slot) if respond_to?(:ruby2_keywords, true)
391
+
392
+ def set_polymorphic_slot(slot_name, poly_type = nil, *args, &block)
393
+ slot_definition = self.class.registered_slots[slot_name]
394
+
395
+ if !slot_definition[:collection] && (defined?(@__vc_set_slots) && @__vc_set_slots[slot_name])
396
+ raise ContentAlreadySetForPolymorphicSlotError.new(slot_name)
397
+ end
398
+
399
+ poly_def = slot_definition[:renderable_hash][poly_type]
400
+
401
+ set_slot(slot_name, poly_def, *args, &block)
143
402
  end
403
+ ruby2_keywords(:set_polymorphic_slot) if respond_to?(:ruby2_keywords, true)
144
404
  end
145
405
  end
@@ -10,12 +10,12 @@ module ViewComponent
10
10
  # @param layout [String] The (optional) layout to use.
11
11
  # @return [Proc] A block that can be used to visit the path of the inline rendered component.
12
12
  def with_rendered_component_path(fragment, layout: false, &block)
13
- # Add './tmp/view_components/' directory if it doesn't exist to store the rendered component HTML
14
- FileUtils.mkdir_p("./tmp/view_components/") unless Dir.exist?("./tmp/view_components/")
15
-
16
- file = Tempfile.new(["rendered_#{fragment.class.name}", ".html"], "tmp/view_components/")
13
+ file = Tempfile.new(
14
+ ["rendered_#{fragment.class.name}", ".html"],
15
+ ViewComponentsSystemTestController.temp_dir
16
+ )
17
17
  begin
18
- file.write(controller.render_to_string(html: fragment.to_html.html_safe, layout: layout))
18
+ file.write(vc_test_controller.render_to_string(html: fragment.to_html.html_safe, layout: layout))
19
19
  file.rewind
20
20
 
21
21
  block.call("/_system_test_entrypoint?file=#{file.path.split("/").last}")