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

@@ -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}")