ViewComponent / view_component

A framework for building reusable, testable & encapsulated view components in Ruby on Rails.
https://viewcomponent.org
MIT License
3.28k stars 429 forks source link

Allow Ruby refinements in ViewComponent classes that impact values used in views #1915

Closed joshuaclayton closed 10 months ago

joshuaclayton commented 11 months ago

Feature Request

I'd like to enable Ruby refinements within the context of a view component where refined values are available within the template.

Motivation

Imagine the following scenario:

  1. A Stimulus controller exists to support simple sorting behavior on a table. Sorting can be done based on the value in the <td> lexicographically or overridden via a data- attribute the controller is aware of for both value and type. e.g. <td data-sortable-type="float" data-sortable-value="<%= example.duration.to_f.round(4) %>">Some other non-numeric content</td>
  2. example.duration is a domain object, Domain::Duration, that operates as a PORO/value object that exists independent of the view.
  3. I'd like to refine Domain::Duration at the ViewComponent level to introduce Domain::Duration#to_sortable_table, which in turn calls to_f.round(4)

With this approach, Domain::Duration can be decorated only within the ViewComponent and without muddying up the Domain::Duration class with view-specific code. If additional domain classes also have long or complex method chains to be able to sort them, the refinements for e.g. that specific Stimulus sortable table can all exist in one spot.

BlakeWilliams commented 11 months ago

👋 hey!

I'm not super familiar with refinements since I haven't used them often, but the view method is defined on the component class so I'd imagine they would work out of the box. If that's not the case, having a failing test would be helpful, but I also I'd accept a PR adding that functionality as long as it doesn't negatively impact performance.

I'd like to refine Domain::Duration at the ViewComponent level to introduce Domain::Duration#to_sortable_table, which in turn calls to_f.round(4)

In case it's helpful in the meantime, for scenarios like this I'd generally suggest folks add a method to the component that accepts the Domain::Duration object and formats it since that's a view concern and is more discoverable (using a refinement to add methods conditionally based on scope feels magical, for better or worse). I haven't used refinements much though, so I'd be curious to hear how that approach works out!

reeganviljoen commented 11 months ago

@camertron I know you have written expensively on the subject of monkey patches and refinements, maybe yoe have some insight here

joshuaclayton commented 11 months ago

@BlakeWilliams well hey! It's worth me calling out I ALSO don't have a ton of experience with refinements, but seemed like a reasonable approach for consolidating decorated behavior for an external dependency (e.g. the Stimulus controller).

My approach here most closely mirrors traits in Rust / typeclasses in Haskell wherein defining a standard method #to_sortable_table can be configured on a per-class basis (but in this case, without muddying the implementation itself).

Minimum reproducible example is here: https://github.com/joshuaclayton/view_component-ruby-refinement

Totally noted on alternative approaches (e.g. a helper method in the component itself); my avoidance of this mirrors why I'd not use view helpers and instead use ViewComponent.

My assumption around this behavior is that the view context itself is a separate class, which makes sense then that the refinement doesn't propagate. There's AFAIK (and seemingly confirmed in https://www.alchemists.io/articles/ruby_refinements#_used) no way to resolve this. In theory, a solution would include any view context understanding the caller's list of refinements and reapplying so they're available within the view layer.

BlakeWilliams commented 11 months ago

My approach here most closely mirrors traits in Rust / typeclasses in Haskell wherein defining a standard method #to_sortable_table can be configured on a per-class basis (but in this case, without muddying the implementation itself).

Fair! Although I imagine the type system is super helpful there. 🙂

My assumption around this behavior is that the view context itself is a separate class, which makes sense then that the refinement doesn't propagate. There's AFAIK (and seemingly confirmed in https://www.alchemists.io/articles/ruby_refinements#_used) no way to resolve this. In theory, a solution would include any view context understanding the caller's list of refinements and reapplying so they're available within the view layer.

That's true for Rails views, but not for components. We define the template method call directly on the class itself: https://github.com/ViewComponent/view_component/blob/main/lib/view_component/compiler.rb#L104

Given that I think a refinement should work with components?

camertron commented 10 months ago

Hey @joshuaclayton, interesting issue! Unfortunately I think what you want here is largely impossible given how refinements work. I don't often use the word "impossible" in the context of Ruby, but in this case it's warranted.

Refinements are one of the only lexically scoped things in the Ruby language, which means they are only available inside the scope where using was called. Lexical scoping is scoping from the point of view of the Ruby lexer, the thing that turns Ruby code into tokens that are eventually fed into the parser. The lexer has no knowledge of what the code actually does, only how it's structured. Any code that wants to make use of refined methods essentially has to be able to see - almost visually-speaking - the using call. The lexical scoping issue is one of the most limiting aspects of refinements and probably a big factor in why they aren't used very often.

The reason the refinements in your RefineString module aren't applied to the component class is because the call to class_eval that defines the template methods is in the ViewComponent::Compiler class and not in your HomeComponent class. This is confusing because the methods themselves are defined on HomeComponent and are therefore scoped to that class when they execute. The lexical scope when they were defined however is the Compiler class, and therein lies the problem. You'd have to put a using RefineString inside the definition of the Compiler class for everything to work as you'd like.

Here's a minimal reproduction case illustrating the problem and the so-called "solution":

module RefineString
  refine String do
    def yell
      upcase
    end
  end
end

class Compiler
  # using RefineString  # uncomment this line to make everything work

  def initialize(klass)
    @klass = klass
  end

  def compile!
    @klass.class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
      def bar
        "hello world".yell
      end
    RUBY
  end
end

class Foo
  using RefineString
end

Compiler.new(Foo).compile!
puts Foo.new.bar

Unfortunately the Compiler class is defined inside ViewComponent, so adding a using statement to it isn't going to work for you. In my opinion, you'll have to consider alternative approaches.

joshuaclayton commented 10 months ago

@camertron I appreciate the explanation, and yes, based on what I'd poked at, would've required a fair bit of hackery to get working 😅. Thanks!