Skip to content

Commit 21b0efa

Browse files
committed
Add default #render_in implementation to ActiveModel::Conversion
Follow-up to [rails#46202][] Without overriding the new `#render_in` method, previous behavior will be preserved: render the view partial determined by calling `#to_partial_path`, then pass `object: self`. With the following view partial: ```erb <%# app/views/people/_person.html.erb %> <% local_assigns.with_defaults(shout: false) => { shout: } %> <%= shout ? person.name.upcase : person.name %> ``` Callers can render an instance of `Person` as a positional argument or a `:renderable` option: ```ruby person = Person.new(name: "Ralph") render person # => "Ralph" render person, shout: true # => "RALPH" render renderable: person # => "Ralph" render renderable: person, locals: { shout: true } # => "RALPH" ``` This preserves backward compatibility. At the same time, the `#render_in` method provides applications with an more flexibility, and an opportunity to manage how to transform models into Strings. For example, users of ViewComponent can map a model directly to a `ViewComponent::Base` instance. [rails#46202]: rails#46202 (comment)
1 parent ed983ca commit 21b0efa

File tree

9 files changed

+127
-3
lines changed

9 files changed

+127
-3
lines changed

actionview/lib/action_view/renderer/abstract_renderer.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def partial_path(object, view)
7979
path = if object.respond_to?(:to_partial_path)
8080
object.to_partial_path
8181
else
82-
raise ArgumentError.new("'#{object.inspect}' is not an ActiveModel-compatible object. It must implement :to_partial_path.")
82+
raise ArgumentError.new("'#{object.inspect}' is not an ActiveModel-compatible object. It must implement #to_partial_path.")
8383
end
8484

8585
if view.prefix_partial_path_with_controller_namespace

actionview/lib/action_view/renderer/template_renderer.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,13 @@ def determine_template(options, &block)
4141
end
4242
Template::Inline.new(options[:inline], "inline template", handler, locals: keys, format: format)
4343
elsif options.key?(:renderable)
44-
Template::Renderable.new(options[:renderable], &block)
44+
renderable = options[:renderable]
45+
46+
unless renderable.respond_to?(:render_in)
47+
raise ArgumentError, "'#{renderable.inspect}' is not a renderable object. It must implement #render_in."
48+
end
49+
50+
Template::Renderable.new(renderable, &block)
4551
elsif options.key?(:template)
4652
if options[:template].respond_to?(:render)
4753
options[:template]

actionview/test/actionpack/controller/render_test.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,10 @@ def partial_hash_collection_with_locals
592592
render partial: "hash_greeting", collection: [ { first_name: "Pratik" }, { first_name: "Amy" } ], locals: { greeting: "Hola" }
593593
end
594594

595+
def renderable_hash
596+
render renderable: Quiz::Question.new(params[:name])
597+
end
598+
595599
def partial_with_implicit_local_assignment
596600
@customer = Customer.new("Marcel")
597601
render partial: "customer"
@@ -706,6 +710,7 @@ class RenderTest < ActionController::TestCase
706710
get :partial_with_nested_object_shorthand, to: "test#partial_with_nested_object_shorthand"
707711
get :partial_with_hashlike_locals, to: "test#partial_with_hashlike_locals"
708712
get :partials_list, to: "test#partials_list"
713+
get :renderable_hash, to: "test#renderable_hash"
709714
get :render_action_hello_world, to: "test#render_action_hello_world"
710715
get :render_action_hello_world_as_string, to: "test#render_action_hello_world_as_string"
711716
get :render_action_hello_world_with_symbol, to: "test#render_action_hello_world_with_symbol"
@@ -1483,6 +1488,11 @@ def test_partial_hash_collection_with_locals
14831488
assert_equal "Hola: PratikHola: Amy", @response.body
14841489
end
14851490

1491+
def test_renderable_hash
1492+
get :renderable_hash, params: { name: "Why?" }
1493+
assert_equal "Why?", @response.body
1494+
end
1495+
14861496
def test_render_missing_partial_template
14871497
assert_raise(ActionView::MissingTemplate) do
14881498
get :missing_partial

actionview/test/template/render_test.rb

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,21 @@ def test_render_partial_with_missing_filename
294294

295295
def test_render_partial_with_incompatible_object
296296
e = assert_raises(ArgumentError) { @view.render(partial: nil) }
297-
assert_equal "'#{nil.inspect}' is not an ActiveModel-compatible object. It must implement :to_partial_path.", e.message
297+
assert_equal "'#{nil.inspect}' is not an ActiveModel-compatible object. It must implement #to_partial_path.", e.message
298+
end
299+
300+
def test_render_renderable_with_nil
301+
assert_raises ArgumentError, match: "'#{nil.inspect}' is not a renderable object. It must implement #render_in." do
302+
@view.render renderable: nil
303+
end
304+
end
305+
306+
def test_render_renderable_with_incompatible_object
307+
object = Object.new
308+
309+
assert_raises ArgumentError, match: "'#{object.inspect}' is not a renderable object. It must implement #render_in." do
310+
@view.render renderable: object
311+
end
298312
end
299313

300314
def test_render_partial_starting_with_a_capital

activemodel/CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,28 @@
1+
* Add default `#render_in` implementation to `ActiveModel::Conversion`
2+
3+
With the following view partial:
4+
5+
```erb
6+
<%# app/views/people/_person.html.erb %>
7+
<% local_assigns.with_defaults(shout: false) => { shout: } %>
8+
9+
<%= shout ? person.name.upcase : person.name %>
10+
```
11+
12+
Callers can render an instance of `Person` as a positional argument or a
13+
`:renderable` option:
14+
15+
```ruby
16+
person = Person.new(name: "Ralph")
17+
18+
render person # => "Ralph"
19+
render person, shout: true # => "RALPH"
20+
render renderable: person # => "Ralph"
21+
render renderable: person, locals: { shout: true } # => "RALPH"
22+
```
23+
24+
*Sean Doyle*
25+
126
* Port the `type_for_attribute` method to Active Model. Classes that include
227
`ActiveModel::Attributes` will now provide this method. This method behaves
328
the same for Active Model as it does for Active Record.

activemodel/lib/active_model/conversion.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,30 @@ def to_partial_path
104104
self.class._to_partial_path
105105
end
106106

107+
# Renders the object into an Action View context.
108+
#
109+
# # app/models/person.rb
110+
# class Person
111+
# include ActiveModel::Conversion
112+
#
113+
# attr_reader :name
114+
#
115+
# def initialize(name)
116+
# @name = name
117+
# end
118+
# end
119+
#
120+
# # app/views/people/_person.html.erb
121+
# <p><%= person.name %></p>
122+
#
123+
# person = Person.new name: "Ralph"
124+
#
125+
# render(person) # => "<p>Ralph</p>
126+
# render(renderable: person) # => "<p>Ralph</p>
127+
def render_in(view_context, **options, &block)
128+
view_context.render(partial: to_partial_path, object: self, **options, &block)
129+
end
130+
107131
module ClassMethods # :nodoc:
108132
# Provide a class level cache for #to_partial_path. This is an
109133
# internal method and should not be accessed directly.

activemodel/lib/active_model/lint.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,21 @@ def test_to_partial_path
6060
assert_kind_of String, model.to_partial_path
6161
end
6262

63+
# Passes if the object's model responds to <tt>render_in</tt>
64+
# calling this method returns a string. Fails otherwise.
65+
#
66+
# <tt>render_in</tt> is used for rendering an instance. For example,
67+
# a BlogPost model might render itself into a "blog_posts/blog_post" partial.
68+
def test_render_in
69+
view_context = Object.new
70+
def view_context.render(...)
71+
""
72+
end
73+
end
74+
assert_respond_to model, :render_in
75+
assert_kind_of String, model.render_in(view_context)
76+
end
77+
6378
# Passes if the object's model responds to <tt>persisted?</tt> and if
6479
# calling this method returns either +true+ or +false+. Fails otherwise.
6580
#

activemodel/test/cases/conversion_test.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,15 @@ def persisted?
6262
assert_equal "attack_helicopters/ah-64", Helicopter::Apache.new.to_partial_path
6363
end
6464

65+
test "#render_in defaults to rendering a partial with the object" do
66+
contact = Contact.new
67+
view_context = Object.new
68+
69+
assert_called_with view_context, :render_in, [{ partial: contact.to_partial_path, object: contact }] do
70+
contact.render_in(view_context)
71+
end
72+
end
73+
6574
test "#to_param_delimiter allows redefining the delimiter used in #to_param" do
6675
old_delimiter = Contact.param_delimiter
6776
Contact.param_delimiter = "_"

guides/source/active_model_basics.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,27 @@ irb> person.to_key
163163
=> nil
164164
irb> person.to_param
165165
=> nil
166+
irb> person.to_partial_path
167+
=> "people/person"
168+
```
169+
170+
The `ActiveModel::Conversion` module also defines a `render_in` method for
171+
integration with Action View. For example, consider a `people/person` partial:
172+
173+
```erb
174+
<%# app/views/people/_person.html.erb %>
175+
<p>Persisted: <%= person.persisted? %></p>
176+
```
177+
178+
By default, passing the object to `render` will invoke `to_partial_path` to
179+
determine the view partial to render:
180+
181+
```ruby
182+
render person
183+
# => <p>Persisted: false</p>
184+
185+
render renderable: person
186+
# => <p>Persisted: false</p>
166187
```
167188

168189
### Dirty

0 commit comments

Comments
 (0)