-
Notifications
You must be signed in to change notification settings - Fork 443
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
Better handling of splatted keyword arguments and with_collection #2020
Comments
@daniel-rikowski would you be able to add some failing tests that we could use as a spec |
@reeganviljoen How shall I approach this? Do you want me to create a PR with just the failing tests? Or shall I post them here before? Anyway, I think I found the root cause. If you have a component without an view_component/lib/view_component/base.rb Lines 708 to 710 in 66dcb7c
returns This stems from the In other words: Without an overridden initialize method, the The following change does seem to fix the whole problem (still, some tests need to be adapted) def initialize_parameters
# Instead of:
# @initialize_parameters ||= instance_method(:initialize).parameters
# This:
@initialize_parameters ||=
instance_method(:initialize).then { |m| m.owner < ViewComponent::Base ? m.parameters : [] }
end |
@daniel-rikowski i have been quite busy lately but I will see if I can find some time to add failing tests and then your research should really come in handy |
I invested a few hours over the weekend. Although the failing tests and the fix are fairly simple, I can't figure out when or why Here's are failing tests: # frozen_string_literal: true
require "test_helper"
module ViewComponent
class UnnamedArgumentsTest < TestCase
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.number}'><h1>#{order.number}</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 setup
@customers = [OpenStruct.new(name: "Taylor"), OpenStruct.new(name: "Rowan")]
@orders = [OpenStruct.new(name: "O-2024-0004"), OpenStruct.new(name: "B-2024-0714")]
end
def test_supports_components_with_argument_forwarding
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
render_inline(OrderComponent.with_collection(@orders))
assert_selector("*[data-name='#{@orders.first.number}']", text: @orders.first.number)
assert_selector("*[data-name='#{@orders.last.number}']", text: @orders.last.number)
end
end
end and this is the fix: module ViewComponent
class Base < ActionView::Base
class << self
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
end
end
end BUT: Now That component has no own class MissingDefaultCollectionParameterComponent < ViewComponent::Base
def call
end
end So it doesn't (=shouldn't) accept a collection argument and the test verifies, that ViewComponent detects this. But in this case it fails, because In other words:
I have no idea why this happens, or how to circumvent it. My original idea to check I tried to reproduce this with minimal code, but I was unable to define a class in which |
Thanks for referring to my abstract components issue. Reviewing what you have here, I'm not sure what you are suggesting is ideal. There is some merit in avoiding duplication via the Edit: Near the bottom is a solution to DRY up the initializers though. Indeed, rather than adding a dynamic base class. It is probably more helpful to modify the generators or make it easier to create your own custom generators that make use of dry-effects if you want. Here is how I would implement the
However, to solve the original issue, I did have ChatGPT assist with quickly generating a solution that uses class_eval. Though I used it for the example above, so it will work with things like:
I use it myself to dry up the places where an initializer isn't needed. Edit: Here it is as a concern.
Edit 2: It seems there are too many possible ways to auto-extract component names when it comes to namespacing things IE: "Record::Buttons::NewComponent" vs "Customer::TableComponent" vs "Customer::Invoice::TableComponent" to the point where I figured I might as well just explicitly define the model name. As such, I have created this instead:
This allows you to just use this:
Edit 3: Allow dynamic attributes.
|
@MyklClason Now I feel bad, because you put so much work into your answer 😩 These are all good ideas, but I'm not looking for new ways to implement these features. The code from the test was actually just intended to mimic some of the functionality of In a nutshell, I want to have this, more or less: https://github.com/palkan/view_component-contrib#hanging-initialize-out-to-dry I.e. be able to do this... class ApplicationViewComponent
extend Dry::Initializer
end
class CustomerComponent < ApplicationViewComponent
option :customer
end ... and be done with it. But ViewComponent actively prevents these components to be used in a collection. (They need to have an explicit |
Anyway, even if it is decided, that my use case is not worth the trouble, it is still a possibility, that some day the Rails developers change the signature of the method, which made this whole workaround necessary. E.g. they change def initialize(...)
@_routes = nil
super
end to - let's say - this: def initialize(*, **options)
@_routes = nil
@_options = options
super
end (Unlikely, admittedly, but it is a possibility.) Then, the afformentioned parameters check would no longer help in preventing misconfigured components, because then, many components get their named splatted arguments from IMHO this is a much deeper problem than just preventing certain component class definitions. |
Lol, no problem. My initializers are more DRY now lol. So I'm happy enough. However, isn't isn't that what the last edit did?
Of course, you can change the method name so it uses "option" instead of "base_initializer". I have seen the "_routes" you mention, but I just ignore it. Here is a version that in theory mimics what you had except it uses "include" rather than "extend". It's been a while since I looked into the differences between those.
Edit: Probable solution below Oh I think I see what we are missing. We also need to make use of "with_collection_parameter" - https://viewcomponent.org/guide/collections.html#with_collection_parameter In that case, we need to make two adjustments:
So we end up with this instead:
And here is the working version of my own (in the off chance I didn't change everything in the above version)
Edit 2: Looks like some additional logic could allow this to support multiple attributes based on this: https://viewcomponent.org/guide/collections.html#additional-arguments |
Feature request
Allow components to have unnamed keyword arguments when using
with_collection
Motivation
This would allow using generic initialize methods with unnamed parameters and provide more flexible opportunities for composition, especially in regards of DRYing up initializers.
Also, I can imagine, this would become a necessity with #2004
Details
Currently these scenarious are not possible:
Example 1: (concept from ViewComponentContrib)
NB:
extend Dry::Initializer
adds this method definition:(
...
is verbatim Ruby code)Example 2: (meta programming)
There is an explicit check to exclude unnamed keyword arguments:
view_component/lib/view_component/base.rb
Lines 695 to 698 in 66dcb7c
I did some rummaging and found this commit 56a6560 It mentions that Ruby 3.1
def m(*)
might implicitly be the same asdef m(*,**)
If that's actually the case it does make sense to forbid unnamed splatted kwargs, but I assume this check can be relaxed for other Ruby versions.
PR
I'm happy to make a PR, but first I want to discuss the proper way to do this, especially considering the fact, that removing the check might introduce subtle bugs.
The text was updated successfully, but these errors were encountered: