From b554fd3bcc4001501eaa91b300615a722155b265 Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Sat, 6 Jan 2024 13:27:35 -0500 Subject: [PATCH 1/3] Pass render options and block to calls to `#render_in` Closes [#45432][] Support for objects that respond to `#render_in` was introduced in [#36388][] and [#37919][]. Those implementations assume that the instance will all the context it needs to render itself. That assumption doesn't account for call-site arguments like `locals: { ... }` or a block. This commit expands support for rendering with a `:renderable` option to incorporate locals and blocks. For example: ```ruby class Greeting def render_in(view_context, **options, &block) if block view_context.render plain: block.call else case Array(options[:formats]).first when :json view_context.render json: { greeting: "Hello, World!" } else view_context.render **options, inline: "<%= Hello, <%= name %>!" end end end end render(Greeting.new) # => "Hello, World!" render(Greeting.new, name: "Local") # => "Hello, Local!" render(Greeting.new) { "Hello, Block!" } # => "Hello, Block!" render(renderable: Greeting.new, formats: :json) # => "{\"greeting\":\"Hello, World!\"}" ``` Since existing tools depend on the `#render_in(view_context)` signature without options, this commit deprecates that signature in favor of one that accepts options and a block. [#45432]: https://github.com/rails/rails/pull/45432 [#36388]: https://github.com/rails/rails/pull/36388 [#37919]: https://github.com/rails/rails/pull/37919 --- actionpack/CHANGELOG.md | 27 +++++++++ .../lib/abstract_controller/rendering.rb | 4 +- .../lib/action_controller/metal/rendering.rb | 34 +++++++++++- actionpack/lib/action_controller/renderer.rb | 4 +- actionpack/test/controller/renderer_test.rb | 34 ++++++++++++ actionpack/test/lib/test_renderable.rb | 16 ++++-- actionview/CHANGELOG.md | 29 ++++++++++ .../action_view/helpers/rendering_helper.rb | 9 +-- .../lib/action_view/renderer/renderer.rb | 12 ++-- .../action_view/renderer/template_renderer.rb | 8 +-- actionview/lib/action_view/rendering.rb | 9 +-- .../lib/action_view/template/renderable.rb | 23 ++++++-- actionview/test/lib/test_renderable.rb | 14 ++++- actionview/test/template/render_test.rb | 38 +++++++++++++ guides/source/layouts_and_rendering.md | 55 +++++++++++++++---- 15 files changed, 270 insertions(+), 46 deletions(-) diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index 98abc0287ca73..f2c7a00c54796 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,30 @@ +* Accept render options and block in `render_to_string` calls made with `:renderable` + + ```ruby + class Greeting + def render_in(view_context, locals: {}, formats: nil, &block) + if block + view_context.render plain: block.call + else + case Array(formats).first + when :json + view_context.render plain: { greeting: "Hello, #{locals.fetch(:name, "World")}!" }.to_json + else + view_context.render inline: <<~ERB.strip, locals: locals + Hello, <%= local_assigns.fetch(:name, "World") %>! + ERB + end + end + end + end + + ApplicationController.render(Greeting.new, name: "Local") # => "Hello, Local!" + ApplicationController.render(Greeting.new) { "Hello, Block!" } # => "Hello, Block!" + ApplicationController.render(renderable: Greeting.new, formats: :json) # => "{\"greeting\":\"Hello, World!\"}" + ``` + + *Sean Doyle* + * Add `allow_browser` to set minimum browser versions for the application. A browser that's blocked will by default be served the file in `public/426.html` with a HTTP status code of "426 Upgrade Required". diff --git a/actionpack/lib/abstract_controller/rendering.rb b/actionpack/lib/abstract_controller/rendering.rb index 18dbb53ce0bbb..9cc8d37def17d 100644 --- a/actionpack/lib/abstract_controller/rendering.rb +++ b/actionpack/lib/abstract_controller/rendering.rb @@ -42,11 +42,11 @@ def render(*args, &block) # needs to be overridden in order to still return a string. def render_to_string(*args, &block) options = _normalize_render(*args, &block) - render_to_body(options) + render_to_body(options, &block) end # Performs the actual template rendering. - def render_to_body(options = {}) + def render_to_body(options = {}, &block) end # Returns +Content-Type+ of rendered content. diff --git a/actionpack/lib/action_controller/metal/rendering.rb b/actionpack/lib/action_controller/metal/rendering.rb index 1d5639d92b59e..4737f66dc0bea 100644 --- a/actionpack/lib/action_controller/metal/rendering.rb +++ b/actionpack/lib/action_controller/metal/rendering.rb @@ -37,7 +37,31 @@ def inherited(klass) # # => renders app/views/posts/show.html.erb # # If the first argument responds to +render_in+, the template will be - # rendered by calling +render_in+ with the current view context. + # rendered by calling +render_in+ with the current view context, render + # options, and block. + # + # class Greeting + # def render_in(view_context, **options, &block) + # if block + # view_context.render html: block.call + # else + # view_context.render inline: <<~ERB.strip, **options + # <%= Hello, <%= local_assigns.fetch(:name, "World") %> + # ERB + # end + # end + # + # def format + # :html + # end + # end + # + # render(Greeting.new) # => "Hello, World" + # render(renderable: Greeting.new) # => "Hello, World" + # render(Greeting.new, name: "Local") # => "Hello, Local" + # render(renderable: Greeting.new, locals: { name: "Local" }) # => "Hello, Local" + # render(Greeting.new) { "Hello, Block" } # => "Hello, Block" + # render(renderable: Greeting.new) { "Hello, Block" } # => "Hello, Block" # # class Greeting # def render_in(view_context) @@ -110,12 +134,16 @@ def inherited(klass) # # [+:renderable+] # Renders the provided object by calling +render_in+ with the current view - # context. The response format is determined by calling +format+ on the - # renderable if it responds to +format+, falling back to +text/html+ by default. + # context, render options, and block. The response format is determined by + # calling +format+ on the renderable if it responds to +format+, falling + # back to +text/html+ by default. # # render renderable: Greeting.new # # => renders "

Hello, World

" # + # render renderable: Greeting.new, locals: { name: "Local" } + # # => renders "Hello, Local" + # # By default, when a rendering mode is specified, no layout template is # rendered. # diff --git a/actionpack/lib/action_controller/renderer.rb b/actionpack/lib/action_controller/renderer.rb index c15ac5ad7f2c5..1c61a71461a8f 100644 --- a/actionpack/lib/action_controller/renderer.rb +++ b/actionpack/lib/action_controller/renderer.rb @@ -120,14 +120,14 @@ def defaults end # Renders a template to a string, just like ActionController::Rendering#render_to_string. - def render(*args) + def render(...) request = ActionDispatch::Request.new(env_for_request) request.routes = controller._routes instance = controller.new instance.set_request! request instance.set_response! controller.make_response!(request) - instance.render_to_string(*args) + instance.render_to_string(...) end alias_method :render_to_string, :render # :nodoc: diff --git a/actionpack/test/controller/renderer_test.rb b/actionpack/test/controller/renderer_test.rb index 490e111745e0d..76488c6806996 100644 --- a/actionpack/test/controller/renderer_test.rb +++ b/actionpack/test/controller/renderer_test.rb @@ -77,6 +77,40 @@ class RendererTest < ActiveSupport::TestCase %(Hello, World!), renderer.render(renderable: TestRenderable.new) ) + assert_equal( + %(Hello, Local!), + renderer.render(TestRenderable.new, name: "Local") + ) + assert_equal( + %(Hello, Local!), + renderer.render(renderable: TestRenderable.new, locals: { name: "Local" }) + ) + end + + test "render a renderable object with :formats" do + renderer = ApplicationController.renderer + + assert_equal( + { greeting: "Hello, World!" }.to_json, + renderer.render(renderable: TestRenderable.new, formats: :json) + ) + assert_equal( + { greeting: "Hello, Local!" }.to_json, + renderer.render(renderable: TestRenderable.new, locals: { name: "Local" }, formats: :json) + ) + end + + test "render a renderable object with block" do + renderer = ApplicationController.renderer + + assert_equal( + %(Goodbye, World!), + renderer.render(TestRenderable.new) { "Goodbye, World!" } + ) + assert_equal( + %(Goodbye, World!), + renderer.render(renderable: TestRenderable.new) { "Goodbye, World!" } + ) end test "rendering with custom env" do diff --git a/actionpack/test/lib/test_renderable.rb b/actionpack/test/lib/test_renderable.rb index 46e2f8983c5c2..36782d4fb89a3 100644 --- a/actionpack/test/lib/test_renderable.rb +++ b/actionpack/test/lib/test_renderable.rb @@ -1,11 +1,17 @@ # frozen_string_literal: true class TestRenderable - def render_in(_view_context) - "Hello, World!" - end + def render_in(view_context, locals: {}, formats: nil, **options, &block) + if block + view_context.render plain: block.call + elsif Array(formats).first == :json + json = { greeting: "Hello, #{locals.fetch(:name, "World")}!" } - def format - :html + view_context.render plain: json.to_json + else + view_context.render inline: <<~ERB.strip, locals: locals + Hello, <%= local_assigns.fetch(:name, "World") %>! + ERB + end end end diff --git a/actionview/CHANGELOG.md b/actionview/CHANGELOG.md index 668bbc48021ad..afbf43be9c4d5 100644 --- a/actionview/CHANGELOG.md +++ b/actionview/CHANGELOG.md @@ -1,3 +1,32 @@ +* Pass render options and block to calls to `#render_in` + + ```ruby + class Greeting + def render_in(view_context, locals: {}, formats: nil, **options, &block) + if block + view_context.render plain: block.call + else + case Array(formats).first + when :json + json = { greeting: "Hello, #{locals.fetch(:name, "World")}!" } + + view_context.render plain: json.to_json + else + view_context.render inline: <<~ERB.strip, locals: locals + Hello, <%= local_assigns.fetch(:name, "World") %>! + ERB + end + end + end + end + + render(Greeting.new, name: "Local") # => "Hello, Local!" + render(Greeting.new) { "Hello, Block!" } # => "Hello, Block!" + render(renderable: Greeting.new, formats: :json) # => "{\"greeting\":\"Hello, World!\"}" + ``` + + *Sean Doyle* + * Add the `nonce: true` option for `stylesheet_link_tag` helper to support automatic nonce generation for Content Security Policy. Works the same way as `javascript_include_tag nonce: true` does. diff --git a/actionview/lib/action_view/helpers/rendering_helper.rb b/actionview/lib/action_view/helpers/rendering_helper.rb index 5be8eca1de30a..c89d3f3df9e92 100644 --- a/actionview/lib/action_view/helpers/rendering_helper.rb +++ b/actionview/lib/action_view/helpers/rendering_helper.rb @@ -24,22 +24,23 @@ module RenderingHelper # If no options hash is passed or if :update is specified, then: # # If an object responding to +render_in+ is passed, +render_in+ is called on the object, - # passing in the current view context. + # passing in the current view context, render options, and block. The + # object can optionally control its rendered format by defining the +format+ method. # # Otherwise, a partial is rendered using the second parameter as the locals hash. def render(options = {}, locals = {}, &block) case options when Hash in_rendering_context(options) do |renderer| - if block_given? + if block_given? && !options.key?(:renderable) view_renderer.render_partial(self, options.merge(partial: options[:layout]), &block) else - view_renderer.render(self, options) + view_renderer.render(self, options, &block) end end else if options.respond_to?(:render_in) - options.render_in(self, &block) + view_renderer.render(self, renderable: options, locals: locals, &block) else view_renderer.render_partial(self, partial: options, locals: locals, &block) end diff --git a/actionview/lib/action_view/renderer/renderer.rb b/actionview/lib/action_view/renderer/renderer.rb index df5138bb0cf53..808a2077d4ed1 100644 --- a/actionview/lib/action_view/renderer/renderer.rb +++ b/actionview/lib/action_view/renderer/renderer.rb @@ -20,15 +20,15 @@ def initialize(lookup_context) end # Main render entry point shared by Action View and Action Controller. - def render(context, options) - render_to_object(context, options).body + def render(context, options, &block) + render_to_object(context, options, &block).body end - def render_to_object(context, options) # :nodoc: + def render_to_object(context, options, &block) # :nodoc: if options.key?(:partial) render_partial_to_object(context, options) else - render_template_to_object(context, options) + render_template_to_object(context, options, &block) end end @@ -54,8 +54,8 @@ def cache_hits # :nodoc: end private - def render_template_to_object(context, options) - TemplateRenderer.new(@lookup_context).render(context, options) + def render_template_to_object(context, options, &block) + TemplateRenderer.new(@lookup_context).render(context, options, &block) end def render_partial_to_object(context, options, &block) diff --git a/actionview/lib/action_view/renderer/template_renderer.rb b/actionview/lib/action_view/renderer/template_renderer.rb index 6f2ae66f5fae1..0d6d77e5b83f8 100644 --- a/actionview/lib/action_view/renderer/template_renderer.rb +++ b/actionview/lib/action_view/renderer/template_renderer.rb @@ -2,9 +2,9 @@ module ActionView class TemplateRenderer < AbstractRenderer # :nodoc: - def render(context, options) + def render(context, options, &block) @details = extract_details(options) - template = determine_template(options) + template = determine_template(options, &block) prepend_formats(template.format) @@ -13,7 +13,7 @@ def render(context, options) private # Determine the template to be rendered using the given options. - def determine_template(options) + def determine_template(options, &block) keys = options.has_key?(:locals) ? options[:locals].keys : [] if options.key?(:body) @@ -41,7 +41,7 @@ def determine_template(options) end Template::Inline.new(options[:inline], "inline template", handler, locals: keys, format: format) elsif options.key?(:renderable) - Template::Renderable.new(options[:renderable]) + Template::Renderable.new(options[:renderable], options[:formats], &block) elsif options.key?(:template) if options[:template].respond_to?(:render) options[:template] diff --git a/actionview/lib/action_view/rendering.rb b/actionview/lib/action_view/rendering.rb index ea5c0cbb43325..d329d6f793e15 100644 --- a/actionview/lib/action_view/rendering.rb +++ b/actionview/lib/action_view/rendering.rb @@ -116,14 +116,14 @@ def view_renderer # :nodoc: @_view_renderer ||= ActionView::Renderer.new(lookup_context) end - def render_to_body(options = {}) + def render_to_body(options = {}, &block) _process_options(options) - _render_template(options) + _render_template(options, &block) end private # Find and render a template based on the options given. - def _render_template(options) + def _render_template(options, &block) variant = options.delete(:variant) assigns = options.delete(:assigns) context = view_context @@ -132,7 +132,7 @@ def _render_template(options) lookup_context.variants = variant if variant rendered_template = context.in_rendering_context(options) do |renderer| - renderer.render_to_object(context, options) + renderer.render_to_object(context, options, &block) end rendered_format = rendered_template.format || lookup_context.formats.first @@ -164,6 +164,7 @@ def _normalize_args(action = nil, options = {}) options = action elsif action.respond_to?(:render_in) options[:renderable] = action + options[:locals] = options else options[:partial] = action end diff --git a/actionview/lib/action_view/template/renderable.rb b/actionview/lib/action_view/template/renderable.rb index c37bd1cb5e2d5..e68e3d64cf520 100644 --- a/actionview/lib/action_view/template/renderable.rb +++ b/actionview/lib/action_view/template/renderable.rb @@ -4,20 +4,35 @@ module ActionView class Template # = Action View Renderable Template for objects that respond to #render_in class Renderable # :nodoc: - def initialize(renderable) + def initialize(renderable, formats, &block) @renderable = renderable + @formats = formats + @block = block end def identifier @renderable.class.name end - def render(context, *args) - @renderable.render_in(context) + def render(context, locals) + options = + if @renderable.method(:render_in).arity == 1 + ActionView.deprecator.warn <<~WARN + Action View's support for #render_in without options is deprecated. + + Change #render_in to accept keyword arguments. + WARN + + {} + else + { formats: @formats, locals: locals } + end + + @renderable.render_in(context, **options, &@block) end def format - @renderable.try(:format) + @renderable.try(:format) || Array(@formats).first end end end diff --git a/actionview/test/lib/test_renderable.rb b/actionview/test/lib/test_renderable.rb index c2b7411e14c30..36782d4fb89a3 100644 --- a/actionview/test/lib/test_renderable.rb +++ b/actionview/test/lib/test_renderable.rb @@ -1,7 +1,17 @@ # frozen_string_literal: true class TestRenderable - def render_in(_view_context) - "Hello, World!" + def render_in(view_context, locals: {}, formats: nil, **options, &block) + if block + view_context.render plain: block.call + elsif Array(formats).first == :json + json = { greeting: "Hello, #{locals.fetch(:name, "World")}!" } + + view_context.render plain: json.to_json + else + view_context.render inline: <<~ERB.strip, locals: locals + Hello, <%= local_assigns.fetch(:name, "World") %>! + ERB + end end end diff --git a/actionview/test/template/render_test.rb b/actionview/test/template/render_test.rb index 4462e8d086811..290f99f00dec7 100644 --- a/actionview/test/template/render_test.rb +++ b/actionview/test/template/render_test.rb @@ -375,9 +375,47 @@ def test_render_renderable_object assert_equal "NilClass", @view.render(partial: "test/klass", object: nil) end + def test_render_renderable_object_without_block_without_options_deprecated + renderable = Object.new + def renderable.render_in(view_context) + end + + assert_deprecated "without options", ActionView.deprecator do + @view.render renderable + end + end + + def test_render_renderable_object_with_block_without_options_deprecated + renderable = Object.new + def renderable.render_in(view_context, &block) + end + + assert_deprecated "without options", ActionView.deprecator do + @view.render renderable + end + end + def test_render_renderable_render_in assert_equal "Hello, World!", @view.render(TestRenderable.new) assert_equal "Hello, World!", @view.render(renderable: TestRenderable.new) + + assert_equal "Hello, Renderable!", @view.render(TestRenderable.new, name: "Renderable") + assert_equal "Hello, Renderable!", @view.render(renderable: TestRenderable.new, locals: { name: "Renderable" }) + + assert_equal "Goodbye, Block!", @view.render(TestRenderable.new) { "Goodbye, Block!" } + assert_equal "Goodbye, Block!", @view.render(renderable: TestRenderable.new) { "Goodbye, Block!" } + + assert_equal({ greeting: "Hello, World!" }.to_json, @view.render(renderable: TestRenderable.new, formats: :json)) + end + + def test_render_renderable_render_in_excludes_renderable_key + renderable = Object.new + def renderable.render_in(view_context, **options) + view_context.render plain: options.to_s, **options + end + options = { formats: :html, locals: { a: true, b: false } } + + assert_equal options.to_s, @view.render(renderable: renderable, **options) end def test_render_object_different_name diff --git a/guides/source/layouts_and_rendering.md b/guides/source/layouts_and_rendering.md index f14a7d6d1de41..7f785419ba799 100644 --- a/guides/source/layouts_and_rendering.md +++ b/guides/source/layouts_and_rendering.md @@ -283,28 +283,63 @@ TIP: `send_file` is often a faster and better option if a layout isn't required. #### Rendering Objects -Rails can render objects responding to `#render_in`. The format can be controlled by defining `#format` on the object. +Rails can render objects that respond to `#render_in`. You can provide the object as the first positional argument or provide it as the `:renderable` option to `render`: ```ruby class Greeting - def render_in(view_context) - view_context.render html: "Hello, World" - end - - def format - :html + def render_in(view_context, **options, &block) + if block + view_context.render plain: block.call + else + case Array(options[:formats]).first + when :json + view_context.render json: { name: options.dig(:locals, :name) } + else + view_context.render inline: <<~ERB.strip, **options + Hello <%= local_assigns.fetch(:name, "World") %> + ERB + end + end end end render Greeting.new # => "Hello World" + +render renderable: Greeting.new +# => "Hello World" + +render Greeting.new, name: "Rails" +# => "Hello Rails" + +render renderable: Greeting.new, locals: { name: "Rails" } +# => "Hello Rails" + +render renderable: Greeting.new, formats: :json +# => "{\"name\":\"World\"}" + +render renderable: Greeting.new, locals: { name: "Rails" }, formats: :json +# => "{\"name\":\"Rails\"}" ``` -This calls `render_in` on the provided object with the current view context. You can also provide the object by using the `:renderable` option to `render`: +If the format is known ahead of rendering, control it by defining `#format` on the object: ```ruby -render renderable: Greeting.new -# => "Hello World" +class Greeting + def render_in(view_context, **options, &block) + if block + view_context.render html: block.call + else + view_context.render inline: <<~ERB, **options + Hello <%= local_assigns.fetch(:name, "World") %> + ERB + end + end + + def format + :html + end +end ``` #### Options for `render` From 1ba6b9f8ed2c3214c82e4927134f6f520d3d2f67 Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Mon, 8 Jan 2024 23:25:27 -0500 Subject: [PATCH 2/3] Remove support for `:format` --- actionpack/CHANGELOG.md | 25 +++++-------- .../lib/action_controller/metal/rendering.rb | 32 ++++------------ actionpack/test/controller/render_test.rb | 15 ++++++++ actionpack/test/controller/renderer_test.rb | 21 ++--------- actionpack/test/lib/test_renderable.rb | 8 +--- actionview/CHANGELOG.md | 24 +++++------- .../action_view/renderer/template_renderer.rb | 2 +- .../lib/action_view/template/renderable.rb | 7 ++-- actionview/test/lib/test_renderable.rb | 12 ++---- actionview/test/template/render_test.rb | 20 +++++----- guides/source/layouts_and_rendering.md | 37 +++++-------------- 11 files changed, 74 insertions(+), 129 deletions(-) diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index f2c7a00c54796..0ce31713eaf45 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,26 +1,21 @@ -* Accept render options and block in `render_to_string` calls made with `:renderable` +* Accept render options and block in `render` calls made with `:renderable` ```ruby class Greeting - def render_in(view_context, locals: {}, formats: nil, &block) + def render_in(view_context, **options, &block) if block - view_context.render plain: block.call + view_context.render html: block.call else - case Array(formats).first - when :json - view_context.render plain: { greeting: "Hello, #{locals.fetch(:name, "World")}!" }.to_json - else - view_context.render inline: <<~ERB.strip, locals: locals - Hello, <%= local_assigns.fetch(:name, "World") %>! - ERB - end - end + view_context.render inline: <<~ERB.strip, **options + Hello, <%= local_assigns.fetch(:name, "World") %>! + ERB end end - ApplicationController.render(Greeting.new, name: "Local") # => "Hello, Local!" - ApplicationController.render(Greeting.new) { "Hello, Block!" } # => "Hello, Block!" - ApplicationController.render(renderable: Greeting.new, formats: :json) # => "{\"greeting\":\"Hello, World!\"}" + ApplicationController.render(Greeting.new) # => "Hello, World!" + ApplicationController.render(Greeting.new) { "Hello, Block!" } # => "Hello, Block!" + ApplicationController.render(renderable: Greeting.new) # => "Hello, World!" + ApplicationController.render(renderable: Greeting.new, locals: { name: "Local" }) # => "Hello, Local!" ``` *Sean Doyle* diff --git a/actionpack/lib/action_controller/metal/rendering.rb b/actionpack/lib/action_controller/metal/rendering.rb index 4737f66dc0bea..4b267b086a8f5 100644 --- a/actionpack/lib/action_controller/metal/rendering.rb +++ b/actionpack/lib/action_controller/metal/rendering.rb @@ -46,7 +46,7 @@ def inherited(klass) # view_context.render html: block.call # else # view_context.render inline: <<~ERB.strip, **options - # <%= Hello, <%= local_assigns.fetch(:name, "World") %> + #

<%= Hello, <%= local_assigns.fetch(:name, "World") %>

# ERB # end # end @@ -56,28 +56,12 @@ def inherited(klass) # end # end # - # render(Greeting.new) # => "Hello, World" - # render(renderable: Greeting.new) # => "Hello, World" - # render(Greeting.new, name: "Local") # => "Hello, Local" - # render(renderable: Greeting.new, locals: { name: "Local" }) # => "Hello, Local" - # render(Greeting.new) { "Hello, Block" } # => "Hello, Block" - # render(renderable: Greeting.new) { "Hello, Block" } # => "Hello, Block" - # - # class Greeting - # def render_in(view_context) - # view_context.render html: "

Hello, World

" - # end - # - # def format - # :html - # end - # end - # - # render(Greeting.new) - # # => "

Hello, World

" - # - # render(renderable: Greeting.new) - # # => "

Hello, World

" + # render(Greeting.new) # => "

Hello, World

" + # render(renderable: Greeting.new) # => "

Hello, World

" + # render(Greeting.new, name: "Local") # => "

Hello, Local

" + # render(renderable: Greeting.new, locals: { name: "Local" }) # => "

Hello, Local

" + # render(Greeting.new) { "

Hello, Block

" } # => "

Hello, Block

" + # render(renderable: Greeting.new) { "

Hello, Block

" } # => "

Hello, Block

" # # ==== \Rendering Mode # @@ -142,7 +126,7 @@ def inherited(klass) # # => renders "

Hello, World

" # # render renderable: Greeting.new, locals: { name: "Local" } - # # => renders "Hello, Local" + # # => renders "

Hello, Local

" # # By default, when a rendering mode is specified, no layout template is # rendered. diff --git a/actionpack/test/controller/render_test.rb b/actionpack/test/controller/render_test.rb index a210d4a2b6bb4..4d340ca8854ea 100644 --- a/actionpack/test/controller/render_test.rb +++ b/actionpack/test/controller/render_test.rb @@ -2,6 +2,7 @@ require "abstract_unit" require "controller/fake_models" +require "test_renderable" class TestControllerWithExtraEtags < ActionController::Base self.view_paths = [ActionView::FixtureResolver.new( @@ -363,6 +364,10 @@ class MetalTestController < ActionController::Metal def accessing_logger_in_template render inline: "<%= logger.class %>" end + + def render_renderable + render renderable: TestRenderable.new, locals: params.fetch(:locals, {}) + end end class ExpiresInRenderTest < ActionController::TestCase @@ -790,6 +795,16 @@ def test_access_to_logger_in_view get :accessing_logger_in_template assert_equal "NilClass", @response.body end + + def test_render_renderable + get :render_renderable + + assert_equal "Hello, World!", @response.parsed_body.text + + get :render_renderable, params: { locals: { name: "Local" } } + + assert_equal "Hello, Local!", @response.parsed_body.text + end end class ActionControllerRenderTest < ActionController::TestCase diff --git a/actionpack/test/controller/renderer_test.rb b/actionpack/test/controller/renderer_test.rb index 76488c6806996..01fe5a89fb0a3 100644 --- a/actionpack/test/controller/renderer_test.rb +++ b/actionpack/test/controller/renderer_test.rb @@ -87,29 +87,16 @@ class RendererTest < ActiveSupport::TestCase ) end - test "render a renderable object with :formats" do - renderer = ApplicationController.renderer - - assert_equal( - { greeting: "Hello, World!" }.to_json, - renderer.render(renderable: TestRenderable.new, formats: :json) - ) - assert_equal( - { greeting: "Hello, Local!" }.to_json, - renderer.render(renderable: TestRenderable.new, locals: { name: "Local" }, formats: :json) - ) - end - test "render a renderable object with block" do renderer = ApplicationController.renderer assert_equal( - %(Goodbye, World!), - renderer.render(TestRenderable.new) { "Goodbye, World!" } + %(

Goodbye, World!

), + renderer.render(TestRenderable.new) { "

Goodbye, World!

".html_safe } ) assert_equal( - %(Goodbye, World!), - renderer.render(renderable: TestRenderable.new) { "Goodbye, World!" } + %(

Goodbye, World!

), + renderer.render(renderable: TestRenderable.new) { "

Goodbye, World!

".html_safe } ) end diff --git a/actionpack/test/lib/test_renderable.rb b/actionpack/test/lib/test_renderable.rb index 36782d4fb89a3..8cf0732e73d44 100644 --- a/actionpack/test/lib/test_renderable.rb +++ b/actionpack/test/lib/test_renderable.rb @@ -1,13 +1,9 @@ # frozen_string_literal: true class TestRenderable - def render_in(view_context, locals: {}, formats: nil, **options, &block) + def render_in(view_context, locals: {}, **options, &block) if block - view_context.render plain: block.call - elsif Array(formats).first == :json - json = { greeting: "Hello, #{locals.fetch(:name, "World")}!" } - - view_context.render plain: json.to_json + view_context.render html: block.call else view_context.render inline: <<~ERB.strip, locals: locals Hello, <%= local_assigns.fetch(:name, "World") %>! diff --git a/actionview/CHANGELOG.md b/actionview/CHANGELOG.md index afbf43be9c4d5..9fd90c4e66ae2 100644 --- a/actionview/CHANGELOG.md +++ b/actionview/CHANGELOG.md @@ -2,27 +2,21 @@ ```ruby class Greeting - def render_in(view_context, locals: {}, formats: nil, **options, &block) + def render_in(view_context, **options, &block) if block - view_context.render plain: block.call + view_context.render html: block.call else - case Array(formats).first - when :json - json = { greeting: "Hello, #{locals.fetch(:name, "World")}!" } - - view_context.render plain: json.to_json - else - view_context.render inline: <<~ERB.strip, locals: locals - Hello, <%= local_assigns.fetch(:name, "World") %>! - ERB - end + view_context.render inline: <<~ERB.strip, **options + Hello, <%= local_assigns.fetch(:name, "World") %>! + ERB end end end - render(Greeting.new, name: "Local") # => "Hello, Local!" - render(Greeting.new) { "Hello, Block!" } # => "Hello, Block!" - render(renderable: Greeting.new, formats: :json) # => "{\"greeting\":\"Hello, World!\"}" + render(Greeting.new) # => "Hello, World!" + render(Greeting.new, name: "Local") # => "Hello, Local!" + render(renderable: Greeting.new, locals: { name: "Local" }) # => "Hello, Local!" + render(Greeting.new) { "Hello, Block!" } # => "Hello, Block!" ``` *Sean Doyle* diff --git a/actionview/lib/action_view/renderer/template_renderer.rb b/actionview/lib/action_view/renderer/template_renderer.rb index 0d6d77e5b83f8..91bb6da05f054 100644 --- a/actionview/lib/action_view/renderer/template_renderer.rb +++ b/actionview/lib/action_view/renderer/template_renderer.rb @@ -41,7 +41,7 @@ def determine_template(options, &block) end Template::Inline.new(options[:inline], "inline template", handler, locals: keys, format: format) elsif options.key?(:renderable) - Template::Renderable.new(options[:renderable], options[:formats], &block) + Template::Renderable.new(options[:renderable], &block) elsif options.key?(:template) if options[:template].respond_to?(:render) options[:template] diff --git a/actionview/lib/action_view/template/renderable.rb b/actionview/lib/action_view/template/renderable.rb index e68e3d64cf520..6f5f86bd73e1e 100644 --- a/actionview/lib/action_view/template/renderable.rb +++ b/actionview/lib/action_view/template/renderable.rb @@ -4,9 +4,8 @@ module ActionView class Template # = Action View Renderable Template for objects that respond to #render_in class Renderable # :nodoc: - def initialize(renderable, formats, &block) + def initialize(renderable, &block) @renderable = renderable - @formats = formats @block = block end @@ -25,14 +24,14 @@ def render(context, locals) {} else - { formats: @formats, locals: locals } + { locals: locals } end @renderable.render_in(context, **options, &@block) end def format - @renderable.try(:format) || Array(@formats).first + @renderable.try(:format) end end end diff --git a/actionview/test/lib/test_renderable.rb b/actionview/test/lib/test_renderable.rb index 36782d4fb89a3..228aa523f5856 100644 --- a/actionview/test/lib/test_renderable.rb +++ b/actionview/test/lib/test_renderable.rb @@ -1,16 +1,12 @@ # frozen_string_literal: true class TestRenderable - def render_in(view_context, locals: {}, formats: nil, **options, &block) + def render_in(view_context, **options, &block) if block - view_context.render plain: block.call - elsif Array(formats).first == :json - json = { greeting: "Hello, #{locals.fetch(:name, "World")}!" } - - view_context.render plain: json.to_json + view_context.render html: block.call else - view_context.render inline: <<~ERB.strip, locals: locals - Hello, <%= local_assigns.fetch(:name, "World") %>! + view_context.render inline: <<~ERB.strip, **options +

Hello, <%= local_assigns.fetch(:name, "World") %>!

ERB end end diff --git a/actionview/test/template/render_test.rb b/actionview/test/template/render_test.rb index 290f99f00dec7..da7193d157d88 100644 --- a/actionview/test/template/render_test.rb +++ b/actionview/test/template/render_test.rb @@ -396,24 +396,22 @@ def renderable.render_in(view_context, &block) end def test_render_renderable_render_in - assert_equal "Hello, World!", @view.render(TestRenderable.new) - assert_equal "Hello, World!", @view.render(renderable: TestRenderable.new) + assert_equal "

Hello, World!

", @view.render(TestRenderable.new) + assert_equal "

Hello, World!

", @view.render(renderable: TestRenderable.new) - assert_equal "Hello, Renderable!", @view.render(TestRenderable.new, name: "Renderable") - assert_equal "Hello, Renderable!", @view.render(renderable: TestRenderable.new, locals: { name: "Renderable" }) + assert_equal "

Hello, Renderable!

", @view.render(TestRenderable.new, name: "Renderable") + assert_equal "

Hello, Renderable!

", @view.render(renderable: TestRenderable.new, locals: { name: "Renderable" }) - assert_equal "Goodbye, Block!", @view.render(TestRenderable.new) { "Goodbye, Block!" } - assert_equal "Goodbye, Block!", @view.render(renderable: TestRenderable.new) { "Goodbye, Block!" } - - assert_equal({ greeting: "Hello, World!" }.to_json, @view.render(renderable: TestRenderable.new, formats: :json)) + assert_equal "

Goodbye, Block!

", @view.render(TestRenderable.new) { @view.tag.h1 "Goodbye, Block!" } + assert_equal "

Goodbye, Block!

", @view.render(renderable: TestRenderable.new) { @view.tag.h1 "Goodbye, Block!" } end def test_render_renderable_render_in_excludes_renderable_key renderable = Object.new def renderable.render_in(view_context, **options) - view_context.render plain: options.to_s, **options + view_context.render plain: options, **options end - options = { formats: :html, locals: { a: true, b: false } } + options = { locals: { a: true, b: false } } assert_equal options.to_s, @view.render(renderable: renderable, **options) end @@ -801,7 +799,7 @@ def test_render_throws_exception_when_no_extensions_passed_to_register_template_ def test_render_object assert_equal( - %(Hello, World!), + %(

Hello, World!

), @view.render(TestRenderable.new) ) end diff --git a/guides/source/layouts_and_rendering.md b/guides/source/layouts_and_rendering.md index 7f785419ba799..16059a956e649 100644 --- a/guides/source/layouts_and_rendering.md +++ b/guides/source/layouts_and_rendering.md @@ -289,52 +289,33 @@ Rails can render objects that respond to `#render_in`. You can provide the objec class Greeting def render_in(view_context, **options, &block) if block - view_context.render plain: block.call + view_context.render html: block.call else - case Array(options[:formats]).first - when :json - view_context.render json: { name: options.dig(:locals, :name) } - else - view_context.render inline: <<~ERB.strip, **options - Hello <%= local_assigns.fetch(:name, "World") %> - ERB - end + view_context.render inline: <<~ERB.strip, **options +

Hello <%= local_assigns.fetch(:name, "World") %>!

+ ERB end end end render Greeting.new -# => "Hello World" +# => "

Hello World!

" render renderable: Greeting.new -# => "Hello World" +# => "

Hello World!

" render Greeting.new, name: "Rails" -# => "Hello Rails" +# => "

Hello Rails!

" render renderable: Greeting.new, locals: { name: "Rails" } -# => "Hello Rails" - -render renderable: Greeting.new, formats: :json -# => "{\"name\":\"World\"}" - -render renderable: Greeting.new, locals: { name: "Rails" }, formats: :json -# => "{\"name\":\"Rails\"}" +# => "

Hello Rails!

" ``` If the format is known ahead of rendering, control it by defining `#format` on the object: ```ruby class Greeting - def render_in(view_context, **options, &block) - if block - view_context.render html: block.call - else - view_context.render inline: <<~ERB, **options - Hello <%= local_assigns.fetch(:name, "World") %> - ERB - end - end + # … def format :html From 6123414fb4473870e739e0b707c41e21cd304685 Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Sat, 6 Jan 2024 16:36:50 -0500 Subject: [PATCH 3/3] Add default `#render_in` implementation to `ActiveModel::Conversion` Follow-up to [#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. [#46202]: https://github.com/rails/rails/pull/46202#issuecomment-1281993332 --- .../action_view/renderer/abstract_renderer.rb | 2 +- .../action_view/renderer/template_renderer.rb | 8 +++++- .../test/actionpack/controller/render_test.rb | 10 ++++++++ actionview/test/template/render_test.rb | 16 +++++++++++- activemodel/CHANGELOG.md | 25 +++++++++++++++++++ activemodel/lib/active_model/conversion.rb | 24 ++++++++++++++++++ activemodel/lib/active_model/lint.rb | 14 +++++++++++ activemodel/test/cases/conversion_test.rb | 15 +++++++++++ guides/source/active_model_basics.md | 21 ++++++++++++++++ 9 files changed, 132 insertions(+), 3 deletions(-) diff --git a/actionview/lib/action_view/renderer/abstract_renderer.rb b/actionview/lib/action_view/renderer/abstract_renderer.rb index 844cf49b01135..e5702654e1b7a 100644 --- a/actionview/lib/action_view/renderer/abstract_renderer.rb +++ b/actionview/lib/action_view/renderer/abstract_renderer.rb @@ -79,7 +79,7 @@ def partial_path(object, view) path = if object.respond_to?(:to_partial_path) object.to_partial_path else - raise ArgumentError.new("'#{object.inspect}' is not an ActiveModel-compatible object. It must implement :to_partial_path.") + raise ArgumentError.new("'#{object.inspect}' is not an ActiveModel-compatible object. It must implement #to_partial_path.") end if view.prefix_partial_path_with_controller_namespace diff --git a/actionview/lib/action_view/renderer/template_renderer.rb b/actionview/lib/action_view/renderer/template_renderer.rb index 91bb6da05f054..b6118a227ac37 100644 --- a/actionview/lib/action_view/renderer/template_renderer.rb +++ b/actionview/lib/action_view/renderer/template_renderer.rb @@ -41,7 +41,13 @@ def determine_template(options, &block) end Template::Inline.new(options[:inline], "inline template", handler, locals: keys, format: format) elsif options.key?(:renderable) - Template::Renderable.new(options[:renderable], &block) + renderable = options[:renderable] + + unless renderable.respond_to?(:render_in) + raise ArgumentError, "'#{renderable.inspect}' is not a renderable object. It must implement #render_in." + end + + Template::Renderable.new(renderable, &block) elsif options.key?(:template) if options[:template].respond_to?(:render) options[:template] diff --git a/actionview/test/actionpack/controller/render_test.rb b/actionview/test/actionpack/controller/render_test.rb index 8c2a9df0b4adc..bf3469095a729 100644 --- a/actionview/test/actionpack/controller/render_test.rb +++ b/actionview/test/actionpack/controller/render_test.rb @@ -592,6 +592,10 @@ def partial_hash_collection_with_locals render partial: "hash_greeting", collection: [ { first_name: "Pratik" }, { first_name: "Amy" } ], locals: { greeting: "Hola" } end + def renderable_hash + render renderable: Quiz::Question.new(params[:name]) + end + def partial_with_implicit_local_assignment @customer = Customer.new("Marcel") render partial: "customer" @@ -706,6 +710,7 @@ class RenderTest < ActionController::TestCase get :partial_with_nested_object_shorthand, to: "test#partial_with_nested_object_shorthand" get :partial_with_hashlike_locals, to: "test#partial_with_hashlike_locals" get :partials_list, to: "test#partials_list" + get :renderable_hash, to: "test#renderable_hash" get :render_action_hello_world, to: "test#render_action_hello_world" get :render_action_hello_world_as_string, to: "test#render_action_hello_world_as_string" 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 assert_equal "Hola: PratikHola: Amy", @response.body end + def test_renderable_hash + get :renderable_hash, params: { name: "Why?" } + assert_equal "Why?", @response.body + end + def test_render_missing_partial_template assert_raise(ActionView::MissingTemplate) do get :missing_partial diff --git a/actionview/test/template/render_test.rb b/actionview/test/template/render_test.rb index da7193d157d88..6751402344120 100644 --- a/actionview/test/template/render_test.rb +++ b/actionview/test/template/render_test.rb @@ -294,7 +294,21 @@ def test_render_partial_with_missing_filename def test_render_partial_with_incompatible_object e = assert_raises(ArgumentError) { @view.render(partial: nil) } - assert_equal "'#{nil.inspect}' is not an ActiveModel-compatible object. It must implement :to_partial_path.", e.message + assert_equal "'#{nil.inspect}' is not an ActiveModel-compatible object. It must implement #to_partial_path.", e.message + end + + def test_render_renderable_with_nil + assert_raises ArgumentError, match: "'#{nil.inspect}' is not a renderable object. It must implement #render_in." do + @view.render renderable: nil + end + end + + def test_render_renderable_with_incompatible_object + object = Object.new + + assert_raises ArgumentError, match: "'#{object.inspect}' is not a renderable object. It must implement #render_in." do + @view.render renderable: object + end end def test_render_partial_starting_with_a_capital diff --git a/activemodel/CHANGELOG.md b/activemodel/CHANGELOG.md index 253ec94bf9c91..2f58b1dbfdde9 100644 --- a/activemodel/CHANGELOG.md +++ b/activemodel/CHANGELOG.md @@ -1,3 +1,28 @@ +* Add default `#render_in` implementation to `ActiveModel::Conversion` + + 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" + ``` + + *Sean Doyle* + * Port the `type_for_attribute` method to Active Model. Classes that include `ActiveModel::Attributes` will now provide this method. This method behaves the same for Active Model as it does for Active Record. diff --git a/activemodel/lib/active_model/conversion.rb b/activemodel/lib/active_model/conversion.rb index c6f4b2fc898ad..6be329671e935 100644 --- a/activemodel/lib/active_model/conversion.rb +++ b/activemodel/lib/active_model/conversion.rb @@ -104,6 +104,30 @@ def to_partial_path self.class._to_partial_path end + # Renders the object into an Action View context. + # + # # app/models/person.rb + # class Person + # include ActiveModel::Conversion + # + # attr_reader :name + # + # def initialize(name) + # @name = name + # end + # end + # + # # app/views/people/_person.html.erb + #

<%= person.name %>

+ # + # person = Person.new name: "Ralph" + # + # render(person) # => "

Ralph

+ # render(renderable: person) # => "

Ralph

+ def render_in(view_context, **options, &block) + view_context.render(partial: to_partial_path, object: self, **options, &block) + end + module ClassMethods # :nodoc: # Provide a class level cache for #to_partial_path. This is an # internal method and should not be accessed directly. diff --git a/activemodel/lib/active_model/lint.rb b/activemodel/lib/active_model/lint.rb index 09527cdd859fb..dd45a65380a25 100644 --- a/activemodel/lib/active_model/lint.rb +++ b/activemodel/lib/active_model/lint.rb @@ -60,6 +60,20 @@ def test_to_partial_path assert_kind_of String, model.to_partial_path end + # Passes if the object's model responds to render_in + # calling this method returns a string. Fails otherwise. + # + # render_in is used for rendering an instance. For example, + # a BlogPost model might render itself into a "blog_posts/blog_post" partial. + def test_render_in + view_context = Object.new + def view_context.render(...) + "" + end + assert_respond_to model, :render_in + assert_kind_of String, model.render_in(view_context) + end + # Passes if the object's model responds to persisted? and if # calling this method returns either +true+ or +false+. Fails otherwise. # diff --git a/activemodel/test/cases/conversion_test.rb b/activemodel/test/cases/conversion_test.rb index eb134595a039e..d7c9eee01adbf 100644 --- a/activemodel/test/cases/conversion_test.rb +++ b/activemodel/test/cases/conversion_test.rb @@ -62,6 +62,21 @@ def persisted? assert_equal "attack_helicopters/ah-64", Helicopter::Apache.new.to_partial_path end + test "#render_in defaults to rendering a partial with the object" do + contact = Contact.new + view_context = Object.new + def view_context.render(**options, &block) + [options, block.call] + end + + options, rendered = contact.render_in(view_context, locals: { size: "small" }) { "block" } + + assert_equal contact.to_partial_path, options[:partial] + assert_equal contact, options[:object] + assert_equal "small", options.dig(:locals, :size) + assert_equal "block", rendered + end + test "#to_param_delimiter allows redefining the delimiter used in #to_param" do old_delimiter = Contact.param_delimiter Contact.param_delimiter = "_" diff --git a/guides/source/active_model_basics.md b/guides/source/active_model_basics.md index e25454bade1a9..a215d81059459 100644 --- a/guides/source/active_model_basics.md +++ b/guides/source/active_model_basics.md @@ -163,6 +163,27 @@ irb> person.to_key => nil irb> person.to_param => nil +irb> person.to_partial_path +=> "people/person" +``` + +The `ActiveModel::Conversion` module also defines a `render_in` method for +integration with Action View. For example, consider a `people/person` partial: + +```erb +<%# app/views/people/_person.html.erb %> +

Persisted: <%= person.persisted? %>

+``` + +By default, passing the object to `render` will invoke `to_partial_path` to +determine the view partial to render: + +```ruby +render person +# =>

Persisted: false

+ +render renderable: person +# =>

Persisted: false

``` ### Dirty