Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ group :development, :test do
gem "bundler", "~> 2"
gem "capybara", "~> 3"
gem "cuprite"
gem "dry-initializer", require: true
gem "erb_lint"
gem "haml", "~> 6"
gem "jbuilder", "~> 2"
Expand Down
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ GEM
diff-lcs (1.6.2)
docile (1.4.1)
drb (2.2.3)
dry-initializer (3.2.0)
erb (5.0.1)
erb_lint (0.9.0)
activesupport
Expand Down Expand Up @@ -393,6 +394,7 @@ DEPENDENCIES
bundler (~> 2)
capybara (~> 3)
cuprite
dry-initializer
erb_lint
haml (~> 6)
jbuilder (~> 2)
Expand Down
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ nav_order: 6

*Joel Hawksley*

* BREAKING: Support compatibility with `Dry::Initializer`. As a result, `EmptyOrInvalidInitializerError` will no longer be raised.

*Joel Hawksley*

## 4.0.0.alpha6

* BREAKING: Remove `config.test_controller` in favor of `vc_test_controller_class` test helper method.
Expand Down
8 changes: 0 additions & 8 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,14 +466,6 @@ It looks like a block was provided after calling `with_content` on COMPONENT, wh

To fix this issue, use either `with_content` or a block.

### `EmptyOrInvalidInitializerError`

The COMPONENT initializer is empty or invalid. It must accept the parameter `PARAMETER` to render it as a collection.

To fix this issue, update the initializer to accept `PARAMETER`.

See [the collections docs](https://viewcomponent.org/guide/collections.html) for more information on rendering collections.

### `HelpersCalledBeforeRenderError`

`#helpers` can't be used before rendering as it depends on the view context that only exists once a ViewComponent is passed to the Rails render pipeline.
Expand Down
1 change: 1 addition & 0 deletions gemfiles/rails_7.1.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ group :development, :test do
gem "bundler", "~> 2"
gem "capybara", "~> 3"
gem "cuprite"
gem "dry-initializer", require: true
gem "erb_lint"
gem "haml", "~> 6"
gem "jbuilder", "~> 2"
Expand Down
1 change: 1 addition & 0 deletions gemfiles/rails_7.2.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ group :development, :test do
gem "bundler", "~> 2"
gem "capybara", "~> 3"
gem "cuprite"
gem "dry-initializer", require: true
gem "erb_lint"
gem "haml", "~> 6"
gem "jbuilder", "~> 2"
Expand Down
1 change: 1 addition & 0 deletions gemfiles/rails_8.0.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ group :development, :test do
gem "bundler", "~> 2"
gem "capybara", "~> 3"
gem "cuprite"
gem "dry-initializer", require: true
gem "erb_lint"
gem "haml", "~> 6"
gem "jbuilder", "~> 2"
Expand Down
1 change: 1 addition & 0 deletions gemfiles/rails_main.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ group :development, :test do
gem "bundler", "~> 2"
gem "capybara", "~> 3"
gem "cuprite"
gem "dry-initializer", require: true
gem "erb_lint"
gem "haml", "~> 6"
gem "jbuilder", "~> 2"
Expand Down
35 changes: 13 additions & 22 deletions lib/view_component/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def config
end

include ActionView::Helpers
include Rails.application.routes.url_helpers if defined?(Rails) && Rails.application
include ERB::Escape
include ActiveSupport::CoreExt::ERBUtil

Expand All @@ -68,8 +69,7 @@ def config
delegate :content_security_policy_nonce, to: :helpers

# Config option that strips trailing whitespace in templates before compiling them.
class_attribute :__vc_strip_trailing_whitespace, instance_accessor: false, instance_predicate: false
self.__vc_strip_trailing_whitespace = false # class_attribute:default doesn't work until Rails 5.2
class_attribute :__vc_strip_trailing_whitespace, instance_accessor: false, instance_predicate: false, default: false

attr_accessor :__vc_original_view_context
attr_reader :current_template
Expand All @@ -89,6 +89,11 @@ def set_original_view_context(view_context)

using RequestDetails

# Including `Rails.application.routes.url_helpers` defines an initializer that accepts (...),
# so we have to define our own empty initializer to overwrite it.
def initialize
end

# Entrypoint for rendering components.
#
# - `view_context`: ActionView context from calling view
Expand Down Expand Up @@ -509,6 +514,11 @@ def with_collection(collection, spacer_component: nil, **args)
Collection.new(self, collection, spacer_component, **args)
end

# @private
def __vc_compile(raise_errors: false, force: false)
__vc_compiler.compile(raise_errors: raise_errors, force: force)
end

# @private
def inherited(child)
# Compile so child will inherit compiled `call_*` template methods that
Expand All @@ -531,12 +541,6 @@ def render_template_for(requested_details)
RUBY
end

# If Rails application is loaded, add application url_helpers to the component context
# we need to check this to use this gem as a dependency
if defined?(Rails) && Rails.application && !(child < Rails.application.routes.url_helpers)
child.include Rails.application.routes.url_helpers
end

# Derive the source location of the component Ruby file from the call stack.
# We need to ignore `inherited` frames here as they indicate that `inherited`
# has been re-defined by the consuming application, likely in ApplicationComponent.
Expand Down Expand Up @@ -568,11 +572,6 @@ def __vc_ensure_compiled
__vc_compile unless __vc_compiled?
end

# @private
def __vc_compile(raise_errors: false, force: false)
__vc_compiler.compile(raise_errors: raise_errors, force: force)
end

# @private
def __vc_compiler
@__vc_compiler ||= Compiler.new(self)
Expand Down Expand Up @@ -622,13 +621,6 @@ def __vc_validate_collection_parameter!(validate_default: false)
return unless parameter
return if __vc_initialize_parameter_names.include?(parameter) || splatted_keyword_argument_present?

# If Ruby can't parse the component class, then the initialize
# parameters will be empty and ViewComponent will not be able to render
# the component.
if initialize_parameters.empty?
raise EmptyOrInvalidInitializerError.new(name, parameter)
end

raise MissingCollectionArgumentError.new(name, parameter)
end

Expand Down Expand Up @@ -670,8 +662,7 @@ def __vc_iteration_argument_present?
private

def splatted_keyword_argument_present?
initialize_parameters.flatten.include?(:keyrest) &&
!initialize_parameters.include?([:keyrest, :**]) # Un-named splatted keyword args don't count!
initialize_parameters.flatten.include?(:keyrest)
end

def __vc_initialize_parameter_names
Expand Down
12 changes: 0 additions & 12 deletions lib/view_component/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,18 +65,6 @@ def initialize(klass_name)
end
end

class EmptyOrInvalidInitializerError < StandardError
MESSAGE =
"The COMPONENT initializer is empty or invalid. " \
"It must accept the parameter `PARAMETER` to render it as a collection.\n\n" \
"To fix this issue, update the initializer to accept `PARAMETER`.\n\n" \
"See [the collections docs](https://viewcomponent.org/guide/collections.html) for more information on rendering collections."

def initialize(klass_name, parameter)
super(MESSAGE.gsub("COMPONENT", klass_name.to_s).gsub("PARAMETER", parameter.to_s))
end
end

class MissingCollectionArgumentError < StandardError
MESSAGE =
"The initializer for COMPONENT doesn't accept the parameter `PARAMETER`, " \
Expand Down
11 changes: 11 additions & 0 deletions test/sandbox/app/components/item_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
require "dry-initializer"

class ItemComponent < ViewComponent::Base
extend Dry::Initializer

option :item

erb_template <<~ERB
<%= item.name %>
ERB
end

This file was deleted.

16 changes: 0 additions & 16 deletions test/sandbox/app/components/product_reader_oops_component.rb

This file was deleted.

103 changes: 72 additions & 31 deletions test/sandbox/test/rendering_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,36 @@ def test_render_inline_allocations
MyComponent.__vc_ensure_compiled

with_instrumentation_enabled_option(false) do
assert_allocations({"3.5" => 69, "3.4" => 74, "3.3" => 73, "3.2" => 72}) do
assert_allocations({"3.5" => 69, "3.4" => 74, "3.3" => 72, "3.2" => 71}) do
render_inline(MyComponent.new)
end
end

assert_selector("div", text: "hello,world!")
end

def test_render_collection_inline_allocations
# Stabilize compilation status ahead of testing allocations to simulate rendering
# performance with compiled component
ViewComponent::CompileCache.cache.delete(ProductComponent)
ProductComponent.__vc_ensure_compiled

allocations = {"3.5" => 77, "3.4" => 82, "3.3" => 86, "3.2" => 84}

products = [Product.new(name: "Radio clock"), Product.new(name: "Mints")]
notice = "On sale"
# Ensure any one-time allocations are done
render_inline(ProductComponent.with_collection(products, notice: notice))

with_instrumentation_enabled_option(false) do
assert_allocations(**allocations) do
render_inline(ProductComponent.with_collection(products, notice: notice))
end
end

assert_selector("h1", text: "Product", count: 2)
end

def test_initialize_super
render_inline(InitializeSuperComponent.new)

Expand Down Expand Up @@ -610,25 +632,6 @@ def test_render_collection
assert_selector("p", text: "Mints counter: 1")
end

def test_render_collection_inline_allocations
# Stabilize compilation status ahead of testing allocations to simulate rendering
# performance with compiled component
ViewComponent::CompileCache.cache.delete(ProductComponent)
ProductComponent.__vc_ensure_compiled

allocations = {"3.5" => 79, "3.4" => 84, "3.3" => 110, "3.2" => 108}

products = [Product.new(name: "Radio clock"), Product.new(name: "Mints")]
notice = "On sale"
# Ensure any one-time allocations are done
render_inline(ProductComponent.with_collection(products, notice: notice))

assert_allocations(**allocations) do
render_inline(ProductComponent.with_collection(products, notice: notice))
end
assert_selector("h1", text: "Product", count: 2)
end

def test_render_collection_custom_collection_parameter_name
coupons = [Coupon.new(percent_off: 20), Coupon.new(percent_off: 50)]
render_inline(ProductCouponComponent.with_collection(coupons))
Expand Down Expand Up @@ -785,17 +788,6 @@ def test_component_with_invalid_named_parameter_names
end
end

def test_collection_component_with_trailing_comma_attr_reader
exception =
assert_raises ViewComponent::EmptyOrInvalidInitializerError do
render_inline(
ProductReaderOopsComponent.with_collection(["foo"])
)
end

assert_match(/ProductReaderOopsComponent initializer is empty or invalid/, exception.message)
end

def test_renders_component_using_rails_config
render_inline(RailsConfigComponent.new)

Expand Down Expand Up @@ -1241,4 +1233,53 @@ def test_custom_base

assert_includes("Hi!", custom_view.render(GreetingComponent.new))
end

def test_dry_initializer
render_inline(ItemComponent.with_collection([Product.new(name: "Radio clock")]))

assert_text("Radio clock")
end

class DynamicComponentBase < ViewComponent::Base
def setup_component(**attributes)
# This method is somewhat contrived, it's intended to mimic features available in the dry-initializer gem.
model_name = self.class.name.demodulize.delete_suffix("Component").underscore.to_sym
instance_variable_set(:"@#{model_name}", attributes[model_name])
define_singleton_method(model_name) { instance_variable_get(:"@#{model_name}") }
end
end

class OrderComponent < DynamicComponentBase
def initialize(**)
setup_component(**)
end

def call
"<div data-name='#{order.name}'><h1>#{order.name}</h1></div>".html_safe
end
end

class CustomerComponent < DynamicComponentBase
def initialize(...)
setup_component(...)
end

def call
"<div data-name='#{customer.name}'><h1>#{customer.name}</h1></div>".html_safe
end
end

def test_supports_components_with_argument_forwarding
customers = [Product.new(name: "Taylor"), Product.new(name: "Rowan")]
render_inline(CustomerComponent.with_collection(customers))
assert_selector("*[data-name='#{customers.first.name}']", text: customers.first.name)
assert_selector("*[data-name='#{customers.last.name}']", text: customers.last.name)
end

def test_supports_components_with_unnamed_splatted_arguments
orders = [Product.new(name: "O-2024-0004"), Product.new(name: "B-2024-0714")]
render_inline(OrderComponent.with_collection(orders))
assert_selector("*[data-name='#{orders.first.name}']", text: orders.first.name)
assert_selector("*[data-name='#{orders.last.name}']", text: orders.last.name)
end
end
Loading