# frozen_string_literal: true require "active_support/core_ext/object/blank" 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 # depending on user input. You are meant to subclass this and define your own # specialized filters based on your application's needs. # # == Query Builders # # The filter, as other query builders, depends on a Proc that accepts both a # +scope+ and the user provided +value+ for this filter. As other # {QueryBuilder Query Builders}, filters respond to {#call}, which evaluates # the proc only if the value is present. # # with_value = Filter.new(:name, value: "Jane Doe") do |scope, value| # scope.where(name: value) # end # without_value = Filter.new(:name, value: nil) do |scope, value| # scope.where(name: value) # end # # scope = User.all # without_value.call(scope) #=> User.all # with_value.call(scope) #=> User.all.where(name: "Jane Doe") # # == Value Sanitization # # By default, the value is passed as-is to the Proc. You might want to format # it or sanitize it in any other way: # # filter = Filter.new( # :name, # value: " string ", # sanitize: ->(value) { value&.strip&.upcase } # ) { |scope, value| scope.where(name => value) } # # filter.call(User.all) #=> User.all.where(name: "STRING") # # *NOTE*: Keep in mind that the value received by +sanitize+ might be +nil+. # # == Default Values # # Usually you want filters to run only when the user supplies a value, but # sometimes you want the query to always be filtered in some way, with the # user having control on the specific value of the filter. # # For example, a query might always return a window of time, but the user # could choose whether that's "last week", "last month", or "last year", and # by default you want this to be "last week". # # # if the user sends 30 (i.e. last 30 days), we will use that value. # filter = Filter.new(:name, value: 30, default: 7) do |scope, value| # scope.where("created_at > ?", value.days.ago) # end # # # if the user doesn't set this filter, we will use 7 as the default value. # filter = Filter.new(:name, value: nil, default: 7) do |scope, value| # scope.where("created_at > ?", value.days.ago) # end # # If the given default is a Proc/lambda, it will be evaluated, and the return # value of the Proc will be used as the default: # # # the default value will be the current user's currency # filter = Filter.new(:currency, default: -> { Current.user.currency }) # # == Rendering filters # # To render a filter in the view, you can call its {#renderer} method, and # pass the output to the +render+ helper: # # <%= render filter.renderer %> # # 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). # # 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 # @!attribute [rw] value # @return [Object, nil] the user-supplied value for this filter. option :value, required: true # @!attribute [rw] sanitize # @return [Proc] a Proc to sanitize the user input. Defaults to a Proc # that returns the input value. option :sanitize, default: IDENT, required: true # @!attribute [rw] default # @return [Object, Proc nil] a default value to use if the user supplies a # blank value. If given a Proc, it will be evaluated and its return # value used as the default. option :default # @!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 # Applies the Proc if the value given by the user is present, and the filter # isn't turned off in another way (e.g. via +if+/+unless+) settings. # # @param scope (see QueryBuilder#call) # @return (see QueryBuilder#call) # @raise (see QueryBuilder#call) def call(scope) super(scope, value).tap do # We only want to consider this filter applied if it has a _custom_ # value set, not if it's just using the default value. @applied = false if @applied && sanitized_value.blank? end end # @return [Object, nil] the value used to decide if the filter should be # applied. This can be a user supplied value (after sanitizing), or the # default value, if set. def value sanitized_value.presence || default_value end # Determine the content of the +<label>+ tag that should be shown when # rendering this filter. This will look up the translation under the # +filters+ namespace. # # @return [String] # @see QueryBuilder#i18n_lookup def label i18n_lookup(:filters) end # Determine the content of the +placeholder+ attribute that should be used # when rendering this filter. This will look up the translation under the # +placeholders+ namespace. # # @return [String] # @see QueryBuilder#i18n_lookup def placeholder i18n_lookup(:placeholders, default: "") end # @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]) else options[:default] end end private def sanitized_value options[:sanitize].call(options[:value]) end end end