Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for multiple formats #2079

Merged
merged 26 commits into from
Sep 6, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
698e991
add test case
joelhawksley Aug 23, 2024
71f4adc
two tests remaining
joelhawksley Aug 23, 2024
d356ce8
first time all green
joelhawksley Aug 23, 2024
7c5cde5
Merge branch 'main' into multi-format-component
joelhawksley Aug 26, 2024
10d68ee
Fix final line endings
github-actions[bot] Aug 26, 2024
229aaab
add docs, changelog, test helpers
joelhawksley Aug 26, 2024
9560ca0
Fix final line endings
github-actions[bot] Aug 26, 2024
9640e67
streamline compiler method generation
joelhawksley Aug 26, 2024
4bacfd9
simplification
joelhawksley Aug 26, 2024
eee9aec
refactor to remove index usage
joelhawksley Aug 26, 2024
1331e41
clearer control flow
joelhawksley Aug 26, 2024
ac048cb
lint
joelhawksley Aug 26, 2024
1170071
Merge branch 'main' into multi-format-component
joelhawksley Aug 26, 2024
7bbba51
md lint
joelhawksley Aug 26, 2024
c44b5b3
remove remaining hardcoded formats
joelhawksley Aug 26, 2024
e46c231
Update lib/view_component/base.rb
joelhawksley Aug 27, 2024
955a52e
Merge branch 'main' into multi-format-component
joelhawksley Aug 27, 2024
6fb636a
remove unnecessary `inspect`
joelhawksley Aug 27, 2024
87978c8
remove unused `identifier`
joelhawksley Aug 27, 2024
b55a94a
inline single-use method
joelhawksley Aug 27, 2024
61a6d61
add safe navigation
joelhawksley Aug 27, 2024
e77aecd
consolidate template collision error messages to include format
joelhawksley Sep 3, 2024
9777f38
add backticks around variant name in error
joelhawksley Sep 4, 2024
69a377f
compiler should check for variant/format render combinations
joelhawksley Sep 6, 2024
0270cba
Merge branch 'main' into multi-format-component
joelhawksley Sep 6, 2024
e0f057f
standardrb
joelhawksley Sep 6, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ nav_order: 5

## main

* Add support for request formats.

*Joel Hawksley*

* Add `rendered_json` test helper.

*Joel Hawksley*

* Add `with_format` test helper.

*Joel Hawksley*

## 3.14.0

* Defer to built-in caching for language environment setup, rather than manually using `actions/cache` in CI.
Expand Down
14 changes: 14 additions & 0 deletions docs/guide/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,20 @@ def test_render_component_for_tablet
end
```

## Request formats

Use the `with_format` helper to test specific request formats:

```ruby
def test_render_component_as_json
with_format :json do
render_inline(MultipleFormatsComponent.new)

assert_equal(rendered_json["hello"], "world")
end
end
```

## Configuring the controller used in tests

Since 2.27.0
Expand Down
12 changes: 6 additions & 6 deletions lib/view_component/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ def render_in(view_context, &block)

if render?
# Avoid allocating new string when output_preamble and output_postamble are blank
rendered_template = safe_render_template_for(@__vc_variant).to_s
rendered_template = safe_render_template_for(@__vc_variant, request.present? ? request.format.to_sym : nil).to_s
joelhawksley marked this conversation as resolved.
Show resolved Hide resolved

if output_preamble.blank? && output_postamble.blank?
rendered_template
Expand Down Expand Up @@ -330,11 +330,11 @@ def maybe_escape_html(text)
end
end

def safe_render_template_for(variant)
def safe_render_template_for(variant, format = nil)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def safe_render_template_for(variant, format = nil)
def safe_render_template_for(variant, format: nil)

extremely minor, but thoughts on using a kwarg for nice readability?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My Personal Ruby Style Guide ™️ says to only use kwargs for > 2 arguments 🤷🏻

Regardless, this method is only used once, so I'm just going to inline it. No point in having default values for a single callsite anyways.

if compiler.renders_template_for_variant?(variant)
render_template_for(variant)
render_template_for(variant, format)
else
maybe_escape_html(render_template_for(variant)) do
maybe_escape_html(render_template_for(variant, format)) do
Kernel.warn("WARNING: The #{self.class} component rendered HTML-unsafe output. The output will be automatically escaped, but you may want to investigate.")
end
end
Expand Down Expand Up @@ -519,12 +519,12 @@ def inherited(child)
# meaning it will not be called for any children and thus not compile their templates.
if !child.instance_methods(false).include?(:render_template_for) && !child.compiled?
child.class_eval <<~RUBY, __FILE__, __LINE__ + 1
def render_template_for(variant = nil)
def render_template_for(variant = nil, format = nil)
# Force compilation here so the compiler always redefines render_template_for.
# This is mostly a safeguard to prevent infinite recursion.
self.class.compile(raise_errors: true, force: true)
# .compile replaces this method; call the new one
render_template_for(variant)
render_template_for(variant, format)
end
RUBY
end
Expand Down
96 changes: 75 additions & 21 deletions lib/view_component/compiler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,14 @@ def call

component_class.silence_redefinition_of_method("render_template_for")
component_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
def render_template_for(variant = nil)
def render_template_for(variant = nil, format = nil)
_call_#{safe_class_name}
end
RUBY
end
else
templates.each do |template|
method_name = call_method_name(template[:variant])
method_name = call_method_name(template[:variant], template[:format])
@variants_rendering_templates << template[:variant]

redefinition_lock.synchronize do
Expand Down Expand Up @@ -106,28 +106,71 @@ def renders_template_for_variant?(variant)
attr_reader :component_class, :redefinition_lock

def define_render_template_for
variant_elsifs = variants.compact.uniq.map do |variant|
safe_name = "_call_variant_#{normalized_variant_name(variant)}_#{safe_class_name}"
branches = []
Copy link
Contributor

@BlakeWilliams BlakeWilliams Aug 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is getting pretty complex, is this something we could break into more methods, or perhaps a separate class that's responsible for returning template objects that define the relevant methods? Like name, method_name, format, etc.?

It might also make the testing story a bit easier and more comprehensive since we don't have to couple the compiler and template logic as heavily.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh man, all I wanted to do while writing this PR was refactor this entire file, but I held back. I'm happy to go down that route, but maybe it would be best to do as a follow-up?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hah, the compiler has gotten a bit gnarly! Part of the reasoning was that I had a hard to following the code in here (but I also didn't have a ton of time to dedicate to reading it) so I thought refactoring might make it easier to follow the changes.

One strategy I've done in the past for changes like this is refactoring the PR on-top of the new behavior, backporting it while removing the new functionality as a separate PR (so 1-to-1 functionality wise), then re-adding those initial refactor changes on-top. It's a bit roundabout, but I found it helps reduce churn and makes the changes easy-to-follow.

I'm also fine with a follow-up, but I'd also say I haven't reviewed this code in depth yet if we wanted to go that route. 🙂

default_method_name = "_call_#{safe_class_name}"

templates.each do |template|
safe_name = +"_call"
variant_name = normalized_variant_name(template[:variant])
safe_name << "_#{variant_name}" if variant_name.present?
safe_name << "_#{template[:format]}" if template[:format].present? && template[:format] != :html
safe_name << "_#{safe_class_name}"

if safe_name == default_method_name
next
else
component_class.define_method(
safe_name,
component_class.instance_method(
call_method_name(template[:variant], template[:format])
)
)
end

format_conditional =
if template[:format] == :html
"(format == :html || format.nil?)"
else
"format == #{template[:format].inspect}"
end

variant_conditional =
if template[:variant].nil?
"variant.nil?"
else
"variant&.to_sym == :'#{template[:variant]}'"
end

branches << ["#{variant_conditional} && #{format_conditional}", safe_name]
end

variants_from_inline_calls(inline_calls).compact.uniq.each do |variant|
safe_name = "_call_#{normalized_variant_name(variant)}_#{safe_class_name}"
component_class.define_method(safe_name, component_class.instance_method(call_method_name(variant)))

"elsif variant.to_sym == :'#{variant}'\n #{safe_name}"
end.join("\n")
branches << ["variant&.to_sym == :'#{variant}'", safe_name]
end

component_class.define_method(:"#{default_method_name}", component_class.instance_method(:call))

component_class.define_method(:"_call_#{safe_class_name}", component_class.instance_method(:call))
# Just use default method name if no conditional branches or if there is a single
# conditional branch that just calls the default method_name
if branches.empty? || (branches.length == 1 && branches[0].last == default_method_name)
body = default_method_name
else
body = +""

body = <<-RUBY
if variant.nil?
_call_#{safe_class_name}
#{variant_elsifs}
else
_call_#{safe_class_name}
branches.each do |conditional, method_body|
body << "#{(!body.present?) ? "if" : "elsif"} #{conditional}\n #{method_body}\n"
end
RUBY

body << "else\n #{default_method_name}\nend"
end

redefinition_lock.synchronize do
component_class.silence_redefinition_of_method(:render_template_for)
component_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
def render_template_for(variant = nil)
def render_template_for(variant = nil, format = nil)
#{body}
end
RUBY
Expand All @@ -147,7 +190,7 @@ def template_errors
errors << "Couldn't find a template file or inline render method for #{component_class}."
end

if templates.count { |template| template[:variant].nil? } > 1
if templates.map { |template| "#{template[:variant].inspect}_#{template[:format]}" }.tally.any? { |_, count| count > 1 }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we use inspect here?

errors <<
"More than one template found for #{component_class}. " \
"There can only be one default template file per component."
Expand Down Expand Up @@ -213,6 +256,7 @@ def templates
pieces = File.basename(path).split(".")
memo << {
path: path,
format: pieces[1..-2].join(".").split("+").first&.to_sym,
variant: pieces[1..-2].join(".").split("+").second&.to_sym,
handler: pieces.last
}
Expand All @@ -239,6 +283,10 @@ def inline_calls_defined_on_self
@inline_calls_defined_on_self ||= component_class.instance_methods(false).grep(/^call(_|$)/)
end

def formats
@__vc_variants = (templates.map { |template| template[:format] }).compact.uniq
end

def variants
@__vc_variants = (
templates.map { |template| template[:variant] } + variants_from_inline_calls(inline_calls)
Expand Down Expand Up @@ -283,12 +331,18 @@ def compile_template(template, handler)
# :nocov:
end

def call_method_name(variant)
if variant.present? && variants.include?(variant)
"call_#{normalized_variant_name(variant)}"
else
"call"
def call_method_name(variant, format = nil)
out = +"call"

if variant.present?
out << "_#{normalized_variant_name(variant)}"
end

if format.present? && format != :html && formats.length > 1
out << "_#{format}"
end

out
end

def normalized_variant_name(variant)
Expand Down
23 changes: 23 additions & 0 deletions lib/view_component/test_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ def render_inline(component, **args, &block)
Nokogiri::HTML.fragment(@rendered_content)
end

# `JSON.parse`-d component output.
#
# ```ruby
# render_inline(MyJsonComponent.new)
# assert_equal(rendered_json["hello"], "world")
# ```
def rendered_json
JSON.parse(rendered_content)
end

# Render a preview inline. Internally sets `page` to be a `Capybara::Node::Simple`,
# allowing for Capybara assertions to be used:
#
Expand Down Expand Up @@ -155,6 +165,19 @@ def with_controller_class(klass)
@vc_test_controller = old_controller
end

# Set format of the current request
#
# ```ruby
# with_format(:json) do
# render_inline(MyComponent.new)
# end
# ```
#
# @param format [Symbol] The format to be set for the provided block.
def with_format(format)
with_request_url("/", format: format) { yield }
end

# Set the URL of the current request (such as when using request-dependent path helpers):
#
# ```ruby
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello, CSS!
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello, HTML!
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
json.hello "world"
4 changes: 4 additions & 0 deletions test/sandbox/app/components/multiple_formats_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# frozen_string_literal: true

class MultipleFormatsComponent < ViewComponent::Base
end
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ def unsafe_postamble_component
render(UnsafePostambleComponent.new)
end

def multiple_formats_component
render(MultipleFormatsComponent.new)
end

def turbo_stream
respond_to { |format| format.turbo_stream { render TurboStreamComponent.new } }
end
Expand Down
1 change: 1 addition & 0 deletions test/sandbox/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
get :unsafe_component, to: "integration_examples#unsafe_component"
get :unsafe_preamble_component, to: "integration_examples#unsafe_preamble_component"
get :unsafe_postamble_component, to: "integration_examples#unsafe_postamble_component"
get :multiple_formats_component, to: "integration_examples#multiple_formats_component"
post :create, to: "integration_examples#create"

constraints(lambda { |request| request.env["warden"].authenticate! }) do
Expand Down
18 changes: 18 additions & 0 deletions test/sandbox/test/integration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -769,4 +769,22 @@ def test_unsafe_postamble_component
"Rendering UnsafePostambleComponent did not emit an HTML safety warning"
)
end

def test_renders_multiple_format_component_as_html
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to Rails default behavior do we want a test that validates we fall back to HTML in cases where the component is missing a format specific template?

Should we also have a test that validates that ViewComponent respects the formats option passed to render if we don't have one already?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. My read of the docs is the opposite, that we should _not _ fall back for an unknown format:

If a template with the specified format does not exist an ActionView::MissingTemplate error is raised.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I wrote a test for the formats option and realized that we don't pass any of the render arguments into components: https://github.com/rails/rails/blob/4bb2227640531c877e30cc96f10df0c298dc3331/actionview/lib/action_view/template/renderable.rb#L16.

IMO we should look at passing render options to renderables, as it would be nice to handle format overrides.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohhhh, you're right. I read that wrong. Good catch.

I was reading:

Rails uses the format specified in the request (or :html by default)

Which I think means instead of passing format = nil in the compiler, it should be format = :html? 🤔

Copy link
Contributor

@camertron camertron Aug 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like we're passing :html by default in the compiler: https://github.com/ViewComponent/view_component/pull/2079/files#diff-fa28fa6e2d5f267384e7793b855281451a46b8437431867871a298fc52fe4940R316

I believe request&.format&.to_sym will also be :html by default, which gets passed to render_template_for.

get "/multiple_formats_component"

assert_includes response.body, "Hello, HTML!"
end

def test_renders_multiple_format_component_as_json
get "/multiple_formats_component.json"

assert_equal response.body, "{\"hello\":\"world\"}"
end

def test_renders_multiple_format_component_as_css
get "/multiple_formats_component.css"

assert_includes response.body, "Hello, CSS!"
end
end
10 changes: 9 additions & 1 deletion test/sandbox/test/rendering_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ def test_renders_haml_template
end

def test_render_jbuilder_template
with_request_url("/", format: :json) do
with_format(:json) do
render_inline(JbuilderComponent.new(message: "bar")) { "foo" }
end

Expand Down Expand Up @@ -1195,4 +1195,12 @@ def test_use_helpers_macros_with_named_prefix

assert_selector ".helper__named-prefix-message", text: "Hello macro named prefix helper method"
end

def test_with_format
with_format(:json) do
render_inline(MultipleFormatsComponent.new)

assert_equal(rendered_json["hello"], "world")
end
end
end
Loading