Skip to content

Commit 451543a

Browse files
joelhawksleygithub-actions[bot]BlakeWilliams
authored
Add support for multiple formats (#2079)
* add test case * two tests remaining * first time all green * Fix final line endings * add docs, changelog, test helpers * Fix final line endings * streamline compiler method generation * simplification * refactor to remove index usage * clearer control flow * lint * md lint * remove remaining hardcoded formats * Update lib/view_component/base.rb Co-authored-by: Blake Williams <[email protected]> * remove unnecessary `inspect` * remove unused `identifier` * inline single-use method * add safe navigation * consolidate template collision error messages to include format * add backticks around variant name in error * compiler should check for variant/format render combinations * standardrb --------- Co-authored-by: GitHub Actions Bot <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Blake Williams <[email protected]>
1 parent 45ffb7a commit 451543a

16 files changed

+219
-91
lines changed

docs/CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,18 @@ nav_order: 5
1010

1111
## main
1212

13+
* Add support for request formats.
14+
15+
*Joel Hawksley*
16+
17+
* Add `rendered_json` test helper.
18+
19+
*Joel Hawksley*
20+
21+
* Add `with_format` test helper.
22+
23+
*Joel Hawksley*
24+
1325
* Warn if using Ruby < 3.1 or Rails < 7.0, which will not be supported by ViewComponent v4.
1426

1527
*Joel Hawksley*

docs/guide/testing.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,20 @@ def test_render_component_for_tablet
135135
end
136136
```
137137

138+
## Request formats
139+
140+
Use the `with_format` helper to test specific request formats:
141+
142+
```ruby
143+
def test_render_component_as_json
144+
with_format :json do
145+
render_inline(MultipleFormatsComponent.new)
146+
147+
assert_equal(rendered_json["hello"], "world")
148+
end
149+
end
150+
```
151+
138152
## Configuring the controller used in tests
139153

140154
Since 2.27.0

lib/view_component/base.rb

Lines changed: 10 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,14 @@ def render_in(view_context, &block)
107107

108108
if render?
109109
# Avoid allocating new string when output_preamble and output_postamble are blank
110-
rendered_template = safe_render_template_for(@__vc_variant).to_s
110+
rendered_template =
111+
if compiler.renders_template_for?(@__vc_variant, request&.format&.to_sym)
112+
render_template_for(@__vc_variant, request&.format&.to_sym)
113+
else
114+
maybe_escape_html(render_template_for(@__vc_variant, request&.format&.to_sym)) do
115+
Kernel.warn("WARNING: The #{self.class} component rendered HTML-unsafe output. The output will be automatically escaped, but you may want to investigate.")
116+
end
117+
end.to_s
111118

112119
if output_preamble.blank? && output_postamble.blank?
113120
rendered_template
@@ -330,16 +337,6 @@ def maybe_escape_html(text)
330337
end
331338
end
332339

333-
def safe_render_template_for(variant)
334-
if compiler.renders_template_for_variant?(variant)
335-
render_template_for(variant)
336-
else
337-
maybe_escape_html(render_template_for(variant)) do
338-
Kernel.warn("WARNING: The #{self.class} component rendered HTML-unsafe output. The output will be automatically escaped, but you may want to investigate.")
339-
end
340-
end
341-
end
342-
343340
def safe_output_preamble
344341
maybe_escape_html(output_preamble) do
345342
Kernel.warn("WARNING: The #{self.class} component was provided an HTML-unsafe preamble. The preamble will be automatically escaped, but you may want to investigate.")
@@ -500,13 +497,6 @@ def with_collection(collection, **args)
500497
Collection.new(self, collection, **args)
501498
end
502499

503-
# Provide identifier for ActionView template annotations
504-
#
505-
# @private
506-
def short_identifier
507-
@short_identifier ||= defined?(Rails.root) ? source_location.sub("#{Rails.root}/", "") : source_location
508-
end
509-
510500
# @private
511501
def inherited(child)
512502
# Compile so child will inherit compiled `call_*` template methods that
@@ -519,12 +509,12 @@ def inherited(child)
519509
# meaning it will not be called for any children and thus not compile their templates.
520510
if !child.instance_methods(false).include?(:render_template_for) && !child.compiled?
521511
child.class_eval <<~RUBY, __FILE__, __LINE__ + 1
522-
def render_template_for(variant = nil)
512+
def render_template_for(variant = nil, format = nil)
523513
# Force compilation here so the compiler always redefines render_template_for.
524514
# This is mostly a safeguard to prevent infinite recursion.
525515
self.class.compile(raise_errors: true, force: true)
526516
# .compile replaces this method; call the new one
527-
render_template_for(variant)
517+
render_template_for(variant, format)
528518
end
529519
RUBY
530520
end
@@ -586,22 +576,6 @@ def compiler
586576
@__vc_compiler ||= Compiler.new(self)
587577
end
588578

589-
# we'll eventually want to update this to support other types
590-
# @private
591-
def type
592-
"text/html"
593-
end
594-
595-
# @private
596-
def format
597-
:html
598-
end
599-
600-
# @private
601-
def identifier
602-
source_location
603-
end
604-
605579
# Set the parameter name used when rendering elements of a collection ([documentation](/guide/collections)):
606580
#
607581
# ```ruby

lib/view_component/collection.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ class Collection
77
include Enumerable
88
attr_reader :component
99

10-
delegate :format, to: :component
1110
delegate :size, to: :@collection
1211

1312
attr_accessor :__vc_original_view_context
@@ -41,6 +40,12 @@ def each(&block)
4140
components.each(&block)
4241
end
4342

43+
# Rails expects us to define `format` on all renderables,
44+
# but we do not know the `format` of a ViewComponent until runtime.
45+
def format
46+
nil
47+
end
48+
4449
private
4550

4651
def initialize(component, object, **options)

lib/view_component/compiler.rb

Lines changed: 104 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class Compiler
1616
def initialize(component_class)
1717
@component_class = component_class
1818
@redefinition_lock = Mutex.new
19-
@variants_rendering_templates = Set.new
19+
@rendered_templates = Set.new
2020
end
2121

2222
def compiled?
@@ -61,22 +61,22 @@ def call
6161

6262
component_class.silence_redefinition_of_method("render_template_for")
6363
component_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
64-
def render_template_for(variant = nil)
64+
def render_template_for(variant = nil, format = nil)
6565
_call_#{safe_class_name}
6666
end
6767
RUBY
6868
end
6969
else
7070
templates.each do |template|
71-
method_name = call_method_name(template[:variant])
72-
@variants_rendering_templates << template[:variant]
71+
method_name = call_method_name(template[:variant], template[:format])
72+
@rendered_templates << [template[:variant], template[:format]]
7373

7474
redefinition_lock.synchronize do
7575
component_class.silence_redefinition_of_method(method_name)
7676
# rubocop:disable Style/EvalWithLocation
7777
component_class.class_eval <<-RUBY, template[:path], 0
7878
def #{method_name}
79-
#{compiled_template(template[:path])}
79+
#{compiled_template(template[:path], template[:format])}
8080
end
8181
RUBY
8282
# rubocop:enable Style/EvalWithLocation
@@ -97,37 +97,80 @@ def #{method_name}
9797
CompileCache.register(component_class)
9898
end
9999

100-
def renders_template_for_variant?(variant)
101-
@variants_rendering_templates.include?(variant)
100+
def renders_template_for?(variant, format)
101+
@rendered_templates.include?([variant, format])
102102
end
103103

104104
private
105105

106106
attr_reader :component_class, :redefinition_lock
107107

108108
def define_render_template_for
109-
variant_elsifs = variants.compact.uniq.map do |variant|
110-
safe_name = "_call_variant_#{normalized_variant_name(variant)}_#{safe_class_name}"
109+
branches = []
110+
default_method_name = "_call_#{safe_class_name}"
111+
112+
templates.each do |template|
113+
safe_name = +"_call"
114+
variant_name = normalized_variant_name(template[:variant])
115+
safe_name << "_#{variant_name}" if variant_name.present?
116+
safe_name << "_#{template[:format]}" if template[:format].present? && template[:format] != :html
117+
safe_name << "_#{safe_class_name}"
118+
119+
if safe_name == default_method_name
120+
next
121+
else
122+
component_class.define_method(
123+
safe_name,
124+
component_class.instance_method(
125+
call_method_name(template[:variant], template[:format])
126+
)
127+
)
128+
end
129+
130+
format_conditional =
131+
if template[:format] == :html
132+
"(format == :html || format.nil?)"
133+
else
134+
"format == #{template[:format].inspect}"
135+
end
136+
137+
variant_conditional =
138+
if template[:variant].nil?
139+
"variant.nil?"
140+
else
141+
"variant&.to_sym == :'#{template[:variant]}'"
142+
end
143+
144+
branches << ["#{variant_conditional} && #{format_conditional}", safe_name]
145+
end
146+
147+
variants_from_inline_calls(inline_calls).compact.uniq.each do |variant|
148+
safe_name = "_call_#{normalized_variant_name(variant)}_#{safe_class_name}"
111149
component_class.define_method(safe_name, component_class.instance_method(call_method_name(variant)))
112150

113-
"elsif variant.to_sym == :'#{variant}'\n #{safe_name}"
114-
end.join("\n")
151+
branches << ["variant&.to_sym == :'#{variant}'", safe_name]
152+
end
153+
154+
component_class.define_method(:"#{default_method_name}", component_class.instance_method(:call))
115155

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

118-
body = <<-RUBY
119-
if variant.nil?
120-
_call_#{safe_class_name}
121-
#{variant_elsifs}
122-
else
123-
_call_#{safe_class_name}
163+
branches.each do |conditional, method_body|
164+
body << "#{(!body.present?) ? "if" : "elsif"} #{conditional}\n #{method_body}\n"
124165
end
125-
RUBY
166+
167+
body << "else\n #{default_method_name}\nend"
168+
end
126169

127170
redefinition_lock.synchronize do
128171
component_class.silence_redefinition_of_method(:render_template_for)
129172
component_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
130-
def render_template_for(variant = nil)
173+
def render_template_for(variant = nil, format = nil)
131174
#{body}
132175
end
133176
RUBY
@@ -147,24 +190,16 @@ def template_errors
147190
errors << "Couldn't find a template file or inline render method for #{component_class}."
148191
end
149192

150-
if templates.count { |template| template[:variant].nil? } > 1
151-
errors <<
152-
"More than one template found for #{component_class}. " \
153-
"There can only be one default template file per component."
154-
end
193+
templates
194+
.map { |template| [template[:variant], template[:format]] }
195+
.tally
196+
.select { |_, count| count > 1 }
197+
.each do |tally|
198+
variant, this_format = tally[0]
155199

156-
invalid_variants =
157-
templates
158-
.group_by { |template| template[:variant] }
159-
.map { |variant, grouped| variant if grouped.length > 1 }
160-
.compact
161-
.sort
200+
variant_string = " for variant `#{variant}`" if variant.present?
162201

163-
unless invalid_variants.empty?
164-
errors <<
165-
"More than one template found for #{"variant".pluralize(invalid_variants.count)} " \
166-
"#{invalid_variants.map { |v| "'#{v}'" }.to_sentence} in #{component_class}. " \
167-
"There can only be one template file per variant."
202+
errors << "More than one #{this_format.upcase} template found#{variant_string} for #{component_class}. "
168203
end
169204

170205
if templates.find { |template| template[:variant].nil? } && inline_calls_defined_on_self.include?(:call)
@@ -213,6 +248,7 @@ def templates
213248
pieces = File.basename(path).split(".")
214249
memo << {
215250
path: path,
251+
format: pieces[1..-2].join(".").split("+").first&.to_sym,
216252
variant: pieces[1..-2].join(".").split("+").second&.to_sym,
217253
handler: pieces.last
218254
}
@@ -239,6 +275,10 @@ def inline_calls_defined_on_self
239275
@inline_calls_defined_on_self ||= component_class.instance_methods(false).grep(/^call(_|$)/)
240276
end
241277

278+
def formats
279+
@__vc_variants = (templates.map { |template| template[:format] }).compact.uniq
280+
end
281+
242282
def variants
243283
@__vc_variants = (
244284
templates.map { |template| template[:variant] } + variants_from_inline_calls(inline_calls)
@@ -258,37 +298,54 @@ def compiled_inline_template(template)
258298
compile_template(template, handler)
259299
end
260300

261-
def compiled_template(file_path)
301+
def compiled_template(file_path, format)
262302
handler = ActionView::Template.handler_for_extension(File.extname(file_path).delete("."))
263303
template = File.read(file_path)
264304

265-
compile_template(template, handler)
305+
compile_template(template, handler, file_path, format)
266306
end
267307

268-
def compile_template(template, handler)
308+
def compile_template(template, handler, identifier = component_class.source_location, format = :html)
269309
template.rstrip! if component_class.strip_trailing_whitespace?
270310

311+
short_identifier = defined?(Rails.root) ? identifier.sub("#{Rails.root}/", "") : identifier
312+
type = ActionView::Template::Types[format]
313+
271314
if handler.method(:call).parameters.length > 1
272-
handler.call(component_class, template)
315+
handler.call(
316+
OpenStruct.new(
317+
format: format,
318+
identifier: identifier,
319+
short_identifier: short_identifier,
320+
type: type
321+
),
322+
template
323+
)
273324
# :nocov:
274325
else
275326
handler.call(
276327
OpenStruct.new(
277328
source: template,
278-
identifier: component_class.identifier,
279-
type: component_class.type
329+
identifier: identifier,
330+
type: type
280331
)
281332
)
282333
end
283334
# :nocov:
284335
end
285336

286-
def call_method_name(variant)
287-
if variant.present? && variants.include?(variant)
288-
"call_#{normalized_variant_name(variant)}"
289-
else
290-
"call"
337+
def call_method_name(variant, format = nil)
338+
out = +"call"
339+
340+
if variant.present?
341+
out << "_#{normalized_variant_name(variant)}"
342+
end
343+
344+
if format.present? && format != :html && formats.length > 1
345+
out << "_#{format}"
291346
end
347+
348+
out
292349
end
293350

294351
def normalized_variant_name(variant)

0 commit comments

Comments
 (0)