From b1903b300a9f7e8c5702c01a87d17eb7c51f55bd Mon Sep 17 00:00:00 2001 From: Nicolas Sanguinetti Date: Tue, 13 Feb 2024 13:05:03 -0500 Subject: [PATCH] Introduce generic renderers Rather than explicitly depending on partials, this allows for a more flexible rendering process on the objects that can be rendered, by declaring a renderer through options. These renderers can still be partials, or component classes, or Procs that determine the renderer dynamically. --- lib/dtb/empty_state.rb | 19 ++--- lib/dtb/filter.rb | 96 +++++++++++++++------ lib/dtb/filter_set.rb | 19 ++--- lib/dtb/has_empty_state.rb | 6 +- lib/dtb/has_filters.rb | 2 +- lib/dtb/renderable.rb | 166 +++++++++++++++++++++++++++++++++++++ test/filter_set_test.rb | 14 +++- test/filter_test.rb | 22 +++-- test/has_filters_test.rb | 24 ++++-- test/renderable_test.rb | 127 ++++++++++++++++++++++++++++ 10 files changed, 425 insertions(+), 70 deletions(-) create mode 100644 lib/dtb/renderable.rb create mode 100644 test/renderable_test.rb diff --git a/lib/dtb/empty_state.rb b/lib/dtb/empty_state.rb index 22704ee..605e0ab 100644 --- a/lib/dtb/empty_state.rb +++ b/lib/dtb/empty_state.rb @@ -2,6 +2,7 @@ require_relative "has_options" require_relative "has_i18n" +require_relative "renderable" module DTB # The Empty State encapsulates the data you might want to render when a query @@ -19,10 +20,17 @@ module DTB # behavior with it. You are welcome to use these strings in any way you want, # or not at all. # + # == Rendering the Empty State + # + # Each empty state is a {Renderable} object, and as such, you can define how + # to render it via the {#render_with} option, and by then calling the + # {#renderer} method. + # # @see HasEmptyState class EmptyState include HasOptions include HasI18n + include Renderable # @!group Options @@ -32,12 +40,6 @@ class EmptyState # ` @see HasI18n#i18n_lookup option :context - # @!attribute [rw] partial - # @return [String, nil] the path to a Rails partial to render for this - # empty state. By default this is +nil+ which means no partial should - # be rendered. - option :partial - # @!endgroup # Determine the "title" of the default empty state container that is @@ -70,10 +72,5 @@ def explanation def update_filters i18n_lookup(:update_filters, :empty_states, context: options[:context], default: "") end - - # (see #partial) - def to_partial_path - options[:partial] - end end end diff --git a/lib/dtb/filter.rb b/lib/dtb/filter.rb index de8185a..aae2f5f 100644 --- a/lib/dtb/filter.rb +++ b/lib/dtb/filter.rb @@ -4,6 +4,7 @@ require "active_support/core_ext/string/inflections" require_relative "query_builder" require_relative "has_options" +require_relative "renderable" module DTB # Filters allow setting conditions on a query, which are optionally applied @@ -71,24 +72,70 @@ module DTB # # == Rendering filters # - # Filters accept a +partial+ option that points to the partial used to render - # them. This lets you render different widgets for each filter, where you can - # customize the form control used (i.e. a text field vs a number field vs a - # select box). + # To render a filter in the view, you can call its {#renderer} method, and + # pass the output to the +render+ helper: # - # When rendering, the filter object is passed to the partial, same as if you - # did: + # <%= render filter.renderer %> # - # render partial: "some_partial", locals: {filter: the_filter} + # To configure how that renderer behaves, Filters accept a +rendes_with+ + # option that defines how they can be rendered. This lets you render different + # widgets for each filter, where you can customize the form control used (i.e. + # a text field vs a number field vs a select box). # - # If you don't specify a partial for a filter, it will try to infer it from - # the filter's class name. So, for example, a +TextFilter+ will try to render - # +filters/text_filter+ while a +SelectFilter+ will try to render - # +filters/select_filter+. + # By default, filters are rendered using a partial template named after the + # filter's class. For example, a +SelectFilter+ would be rendered in the + # +"filters/select_filter"+ partial. The partial receives a local named + # +filter+ with the filter object. + # + # Alternatively, you can pass a callable to +render_with+ that returns valid + # attributes for ActionView's +render+ method. This could be a Hash (i.e. to + # +render+ a custom partial with extra options) or it could be an object that + # responds to +render_in+. + # + # Finally, you can just pass a Class. If you do, DTB will insantiate it with a + # +filter+ keyword, and return the instance. This is useful when using + # component libraries such as ViewComponent or Phlex. + # + # class SelectFilter < DTB::Filter + # option :render_with, default: SelectFilterComponent + # end + # + # == Passing extra options to the renderer + # + # Whatever options you pass to the {#renderer} method, they will be + # forwarded to the configured renderer via {#render_with}. For example, + # given: + # + # class SelectFilter < DTB::Filter + # option :render_with, default: SelectFilterComponent + # end + # + # The following two statements are equivalent + # + # <%= render filter.renderer(class: "custom-class") %> + # <%= render SelectFilterComponent.new(filter: filter, class: "custom-class") %> + # + # == Overriding the options passed to the renderer + # + # The default options passed to the rendered are the return value of the + # {#rendering_options} method. You can always override it to customize how the + # object is passed to the renderer, or to pass other options that you always + # need to include (rather than passing them on every {#renderer}) invocation. + # + # @example Overriding the rendering options + # class AutocompleteFilter < DTB::Filter + # option :render_with, default: AutocompleteFilterComponent + # + # def rendering_options + # # super here returns `{filter: self}` + # {url: autocomplete_url}.update(super) + # end + # end # # @see HasFilters class Filter < QueryBuilder include HasOptions + include Renderable # @!group Options @@ -107,10 +154,13 @@ class Filter < QueryBuilder # value used as the default. option :default - # @!attribute [rw] partial - # @return [String, nil] a custom partial to use when rendering the filter. - # Defaults to the filter's class name, underscored. - option :partial + # @!attribute [rw] render_with + # @see Renderable#render_with + option :render_with, + default: ->(filter:, **opts) { + {partial: "filters/#{filter.class.name.underscore}", locals: {filter: filter, **opts}} + }, + required: true # @!endgroup @@ -155,21 +205,15 @@ def placeholder i18n_lookup(:placeholders, default: "") end - # Determine the partial to be used when rendering this filter. If the - # +partial+ option is set, that is used. If not, this will infer the partial - # should be based on the name of the class, such that FooFilter tries to - # render +filters/foo_filter+. - # - # @return [String] - def to_partial_path - options.fetch(:partial, "filters/#{self.class.name.underscore}") - end - - # @visibility private + # @api private def evaluate? value.present? && super end + private def rendering_options + {filter: self} + end + private def default_value if options[:default].respond_to?(:call) evaluate(with: options[:default]) diff --git a/lib/dtb/filter_set.rb b/lib/dtb/filter_set.rb index 7441afd..447464c 100644 --- a/lib/dtb/filter_set.rb +++ b/lib/dtb/filter_set.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative "query_builder_set" +require_relative "renderable" module DTB # Filter sets extend {QueryBuilderSet QueryBuilder sets} by adding a few @@ -25,6 +26,8 @@ module DTB # <% end %> # class FilterSet < QueryBuilderSet + include Renderable + # @!group Options # @!attribute [rw] param @@ -36,10 +39,8 @@ class FilterSet < QueryBuilderSet # @see #namespace option :param, default: :filters, required: true - # @!attribute [rw] partial - # @return [String] the partial to use to render the filters form. Defaults - # to +"filters/filters"+. - option :partial, default: "filters/filters", required: true + # @!attribute [rw] renders_with (see Renderable#renders_with) + option :render_with, default: "filters/filters", required: true # @!attribute [rw] submit_url # @return [String] the URL to submit the filters form to. @@ -70,12 +71,6 @@ def namespace options[:param] end - # @return [String] the rails partial used to render this form. - # @see #partial - def to_partial_path - options[:partial] - end - def submit_url options[:submit_url] end @@ -83,5 +78,9 @@ def submit_url def reset_url options[:reset_url] end + + private def rendering_options + {filters: self} + end end end diff --git a/lib/dtb/has_empty_state.rb b/lib/dtb/has_empty_state.rb index a1576c7..add9de2 100644 --- a/lib/dtb/has_empty_state.rb +++ b/lib/dtb/has_empty_state.rb @@ -10,14 +10,12 @@ module DTB # # @example Configuring a default partial to render empty states # class ApplicationQuery < DTB::Query - # options[:empty_state][:partial] = "data_tables/empty_state" + # options[:empty_state][:render_with] = "data_tables/empty_state" # end # # @example Rendering the empty state of a data table # <% if data_table.empty? %> - # <%= render partial: data_table.empty_state, - # as: :empty_state, - # locals: { data_table: data_table } %> + # <%= render data_table.empty_state.renderer(data_table: data_table) %> # <% end %> # # @example A sample default empty state partial diff --git a/lib/dtb/has_filters.rb b/lib/dtb/has_filters.rb index dad3704..273bcd0 100644 --- a/lib/dtb/has_filters.rb +++ b/lib/dtb/has_filters.rb @@ -58,7 +58,7 @@ module HasFilters # filter :name, # type: ContainsTextFilter # - # @example Overriding the partial used for a specific filter + # @example Overriding the renderer used for a specific filter # # Instead of rendering "filters/contains_text_filter", this would # # render "example/partial" in the filters form. # filter :name, diff --git a/lib/dtb/renderable.rb b/lib/dtb/renderable.rb new file mode 100644 index 0000000..d132fb8 --- /dev/null +++ b/lib/dtb/renderable.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require "active_support/concern" +require "active_model/naming" +require_relative "has_options" + +module DTB + # Provides a simple abstraction for rendering components by setting an option + # with the object to use for rendering. + # + # This povides a {#renderer} method that you can pass to ActionView's + # +render+, like so: + # + # <%= render @object.renderer %> + # + # == Rendering partials + # + # In the most basic case, you can pass a string to the +:render_with+ option + # to render the component via a partial. When doing so, the return value of + # the {#rendering_options} method will be passed as locals. + # + # @example Rendering via a partial + # class SelectFilter < DTB::Filter + # option :render_with, default: "filters/select_filter" + # end + # + # In that example, +renderer+ will return a Hash that looks like this: + # + # {partial: "filters/select_filter", locals: {filter: }} + # + # == Rendering components + # + # If you're using a component library such as ViewComponent or Phlex, you can + # pass a component directly to the +:render_with+ option. The component will + # be instantiated with the object. + # + # @example Rendering a ViewComponent + # class SelectFilter < DTB::Filter + # option :render_with, default: SelectFilterComponent + # end + # + # In that example, calling +renderer+ will be equivalent to insantiating the + # component like so: + # + # SelectFilterComponent.new(filter: ) + # + # == Dynamic renderer resolution + # + # If you pass a callable to +:render_with+, it will be called with the object + # as a keyword argument. The callable is expected to return a valid argument + # for ActionView's +render+ method. + # + # @example Dynamic partial selection + # class SelectFilter < DTB::Filter + # option :render_with, default: ->(filter:, **opts) { + # if filter.autocomplete? + # {partial: "filters/autocomplete_filter", locals: {filter: filter, **opts}} + # else + # {partial: "filters/select_filter", locals: {filter: filter, **opts}} + # end + # } + # end + # + # == Passing extra options to the renderer + # + # Whatever options you pass to the {#renderer} method, they will be + # forwarded to the configured renderer via {#render_with}. + # + # If rendering with a partial, these will be passed as extra locals. If using + # a component-based renderer, these will be passed as extra keyword arguments + # to the initializer. + # + # @example Passing extra locals to a partial + # <%= render filter.renderer(css_class: "custom-class") %> + module Renderable + extend ActiveSupport::Concern + include HasOptions + + included do + # @!group Options + + # @!attribute [rw] render_with + # @return [#call, Class<#render_in>, Hash] an object that can be used to + # render the Renderable, that will be passed to ActionView's #render. + # @see #renderer + # @see #rendering_options + option :render_with + + # @!endgroup + end + + # Returns an object capable of being rendered by ActionView's +render+, + # based on what the +:render_with+ option is set to. + # + # * If +:render_with+ is a string, it will return a Hash with the +:partial+ + # key set to the string, and the +:locals+ key set to the return value of + # the {#rendering_options} method, plus any extra options passed to this + # method. + # + # * If +:render_with+ is a class, it will return an instance of that class + # with the return value of the {#rendering_options} method and any extra + # options passed to this method as keyword arguments. + # + # * If +:render_with+ is a callable, it will call it with the return value + # of the {#rendering_options} method and any extra options passed to this + # method as keyword arguments. + # + # @param opts [Hash] extra options to pass to the renderer. + # @return [Hash, #render_in] an object that can be used as an argument to + # ActionView's +render+ method. + # + # @see #rendering_options + def renderer(**opts) + render_with = options[:render_with] + opts = opts.update(rendering_options) + + if render_with.respond_to?(:call) + render_with.call(**opts) + elsif render_with.is_a?(Class) + render_with.new(**opts) + elsif render_with.respond_to?(:to_str) + {partial: render_with, locals: opts} + else + render_with + end + end + + # Returns a Hash of options to pass to the renderer. By default, this will + # include a reference to the Renderable itself, under a key that is derived + # from its class name, underscored, after removing any class namespace. + # + # @example Default rendering options + # class MyFilter + # include DTB::Renderable + # end + # + # filter = MyFilter.new + # filter.rendering_options # => {my_filter: filter} + # + # @example Default rendering options in a namespaced object + # class Admin::Widget + # include DTB::Renderable + # end + # + # widget = Admin::Widget.new + # widget.rendering_options # => {widget: widget} + # + # @example Overridden rendering options + # class MyFilter + # include DTB::Renderable + # + # def rendering_options + # {filter: self, custom: "option"} + # end + # end + # + # filter = MyFilter.new + # filter.rendering_options # => {filter: filter, custom: "option"} + # + # @return [Hash] + def rendering_options + name = ActiveModel::Name.new(self.class).element.underscore.to_sym + {name => self} + end + end +end diff --git a/test/filter_set_test.rb b/test/filter_set_test.rb index 00e04d4..8efbfd3 100644 --- a/test/filter_set_test.rb +++ b/test/filter_set_test.rb @@ -11,12 +11,18 @@ def test_provides_a_namespace_for_form_params assert_equal :f, overridden.namespace end - def test_determins_its_partial_path + def test_determins_its_rendering_options filters = DTB::FilterSet.new([]) - assert_equal "filters/filters", filters.to_partial_path + assert_equal( + {partial: "filters/filters", locals: {filters: filters}}, + filters.renderer + ) - overridden = DTB::FilterSet.new([], partial: "filters/horizontal") - assert_equal "filters/horizontal", overridden.to_partial_path + overridden = DTB::FilterSet.new([], render_with: "filters/horizontal") + assert_equal( + {partial: "filters/horizontal", locals: {filters: overridden}}, + overridden.renderer + ) end def test_accepts_urls_for_submit_and_reset diff --git a/test/filter_test.rb b/test/filter_test.rb index 33a0f29..f6b737b 100644 --- a/test/filter_test.rb +++ b/test/filter_test.rb @@ -68,15 +68,27 @@ def test_default_value_can_be_a_proc assert_equal "value", filter.value end - def test_determines_its_partial_path + def test_provides_a_default_rendering_mechanism base_filter = DTB::Filter.new(:foo, value: 1) - assert_equal "filters/dtb/filter", base_filter.to_partial_path + assert_equal( + {partial: "filters/dtb/filter", locals: {filter: base_filter}}, + base_filter.renderer + ) test_filter = TestFilter.new(:bar, value: 2) - assert_equal "filters/test_filter", test_filter.to_partial_path + assert_equal({partial: "filters/test_filter", locals: {filter: test_filter}}, test_filter.renderer) - override_filter = DTB::Filter.new(:foo, value: 1, partial: "filters/override") - assert_equal "filters/override", override_filter.to_partial_path + override_filter = DTB::Filter.new(:foo, { + value: 1, + render_with: ->(filter:) { {partial: "filters/override", locals: {filter: filter}} } + }) + assert_equal({partial: "filters/override", locals: {filter: override_filter}}, override_filter.renderer) + + component_class = Struct.new(:filter, keyword_init: true) + component_filter = DTB::Filter.new(:foo, value: 1, render_with: component_class) + renderable = component_filter.renderer + assert_instance_of component_class, renderable + assert_equal component_filter, renderable.filter end def test_defaults_to_root_i18n_keys_for_nil_contexts diff --git a/test/has_filters_test.rb b/test/has_filters_test.rb index a718479..9a13298 100644 --- a/test/has_filters_test.rb +++ b/test/has_filters_test.rb @@ -20,7 +20,7 @@ class TestClass filter :qux, ->(scope, val) { scope << val << internal_state }, - partial: "filters/super_special" + render_with: "filters/super_special" def run filters.call([]) @@ -38,25 +38,25 @@ def test_defines_filters_on_instances assert_kind_of DTB::Filter, foo assert_equal :foo, foo.name assert_nil foo.value - assert_equal "filters/dtb/filter", foo.to_partial_path + assert_equal({partial: "filters/dtb/filter", locals: {filter: foo}}, foo.renderer) bar = object.filters[:bar] assert_kind_of TestFilter, bar assert_equal :bar, bar.name assert_equal :bar_default, bar.value - assert_equal "filters/test_filter", bar.to_partial_path + assert_equal({partial: "filters/test_filter", locals: {filter: bar}}, bar.renderer) baz = object.filters[:baz] assert_kind_of DTB::Filter, baz assert_equal :baz, baz.name assert_nil baz.value - assert_equal "filters/dtb/filter", baz.to_partial_path + assert_equal({partial: "filters/dtb/filter", locals: {filter: baz}}, baz.renderer) qux = object.filters[:qux] assert_kind_of DTB::Filter, qux assert_equal :qux, qux.name assert_nil qux.value - assert_equal "filters/super_special", qux.to_partial_path + assert_equal({partial: "filters/super_special", locals: {filter: qux}}, qux.renderer) end def test_expects_params_in_initializer @@ -93,10 +93,16 @@ def test_removes_filter_params_from_reset_url def test_can_override_partial object = TestClass.new({}) - assert_equal "filters/filters", object.filters.to_partial_path - - overridden = TestClass.new({}, filters: {partial: "filters/horizontal"}) - assert_equal "filters/horizontal", overridden.filters.to_partial_path + assert_equal( + {partial: "filters/filters", locals: {filters: object.filters}}, + object.filters.renderer + ) + + overridden = TestClass.new({}, filters: {render_with: "filters/horizontal"}) + assert_equal( + {partial: "filters/horizontal", locals: {filters: overridden.filters}}, + overridden.filters.renderer + ) end def test_to_data_table_includes_filters diff --git a/test/renderable_test.rb b/test/renderable_test.rb new file mode 100644 index 0000000..a546a37 --- /dev/null +++ b/test/renderable_test.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require "test_helper" + +class DTB::RenderableTest < Minitest::Test + class SimpleRenderable + include DTB::Renderable + + option :render_with, default: "partial" + end + + class OverriddenOptionsRenderable + include DTB::Renderable + + option :render_with, default: "partial" + + def rendering_options + {object: self} + end + end + + def test_renders_with_default_value + renderable = SimpleRenderable.new + + assert_equal( + {partial: "partial", locals: {simple_renderable: renderable}}, + renderable.renderer + ) + + assert_equal( + {partial: "partial", locals: {simple_renderable: renderable, foo: :bar}}, + renderable.renderer(foo: :bar) + ) + end + + def test_overrides_renderable_as_option + renderable = SimpleRenderable.new(render_with: "overridden") + + assert_equal( + {partial: "overridden", locals: {simple_renderable: renderable}}, + renderable.renderer + ) + + assert_equal( + {partial: "overridden", locals: {simple_renderable: renderable, foo: :bar}}, + renderable.renderer(foo: :bar) + ) + end + + def test_renders_with_component_class + component_class = Struct.new(:simple_renderable, :foo, keyword_init: true) + renderable = SimpleRenderable.new(render_with: component_class) + + component = renderable.renderer + assert_instance_of component_class, component + assert_equal renderable, component.simple_renderable + assert_nil component.foo + + foo_component = renderable.renderer(foo: :bar) + assert_instance_of component_class, foo_component + assert_equal renderable, foo_component.simple_renderable + assert_equal :bar, foo_component.foo + end + + def test_renders_with_proc + renderable = SimpleRenderable.new(render_with: ->(**opts) { opts }) + assert_equal({simple_renderable: renderable}, renderable.renderer) + + assert_equal( + {simple_renderable: renderable, foo: :bar}, + renderable.renderer(foo: :bar) + ) + end + + def test_renders_with_default_value_and_custom_options + renderable = OverriddenOptionsRenderable.new + + assert_equal( + {partial: "partial", locals: {object: renderable}}, + renderable.renderer + ) + + assert_equal( + {partial: "partial", locals: {object: renderable, foo: :bar}}, + renderable.renderer(foo: :bar) + ) + end + + def test_overrides_renderable_as_option_with_custom_options + renderable = OverriddenOptionsRenderable.new(render_with: "overridden") + + assert_equal( + {partial: "overridden", locals: {object: renderable}}, + renderable.renderer + ) + + assert_equal( + {partial: "overridden", locals: {object: renderable, foo: :bar}}, + renderable.renderer(foo: :bar) + ) + end + + def test_renders_with_component_class_and_custom_options + component_class = Struct.new(:object, :foo, keyword_init: true) + renderable = OverriddenOptionsRenderable.new(render_with: component_class) + + component = renderable.renderer + assert_instance_of component_class, component + assert_equal renderable, component.object + assert_nil component.foo + + foo_component = renderable.renderer(foo: :bar) + assert_instance_of component_class, foo_component + assert_equal renderable, foo_component.object + assert_equal :bar, foo_component.foo + end + + def test_renders_with_proc_and_custom_options + renderable = OverriddenOptionsRenderable.new(render_with: ->(**opts) { opts }) + assert_equal({object: renderable}, renderable.renderer) + + assert_equal( + {object: renderable, foo: :bar}, + renderable.renderer(foo: :bar) + ) + end +end