Dynamic filters
Dynamic filters let users compose their own filtering on the Index view. You declare which fields are filterable; Avo renders a filters bar where the user picks an attribute, a condition (Contains, Is, >=, Is null, …), and a value — and can stack as many conditions as they need. Queries are built with Ransack behind the scenes.
# app/avo/resources/project.rb
class Avo::Resources::Project < Avo::BaseResource
def fields
field :name, as: :text
field :status, as: :status, filterable: true
field :stage, as: :badge, filterable: true
field :country, as: :country, filterable: true
end
endWith no further configuration, each filterable field gets a filter type inferred from its field type, a humanized label, a default icon, and that type's default conditions. The filters bar is always visible on the resources where at least one field is filterable.
Authorize the attributes for Ransack
Since Ransack builds the queries, every filterable attribute must be present in the model's ransackable_attributes list:
# app/models/project.rb
class Project < ApplicationRecord
def self.ransackable_attributes(auth_object = nil)
["status", "stage", "country"] # the array items should be strings not symbols
end
end
# Or authorize ALL attributes at once
class Project < ApplicationRecord
def self.ransackable_attributes(auth_object = nil)
authorizable_ransackable_attributes
end
endWARNING
Ensure the array items are strings, not symbols.
How conditions combine
When multiple filters are applied:
- Filters on the same attribute are combined with
OR - Filters on different attributes are combined with
AND
For example, two filters on name (one for "John", one for "Jane") find records where the name is either "John" OR "Jane". A filter on name for "John" plus a filter on status for "active" finds records where the name is "John" AND the status is "active".
Filter types
The filter type determines the input the user sees and the conditions they can choose from. A text filter renders a text input with conditions like Contains or Starts with; a number filter renders a number input with =, >, <=, and so on.
There are eight types: :boolean, :date, :date_time, :time, :number, :select, :text, and :tags. Each type's conditions, icons, and quirks are cataloged in the API reference.
Avo picks the type automatically from the field type — a boolean field gets a boolean filter, a badge field gets a select filter, and so on, falling back to text. The full mapping is in the API reference, and you can always override it with the type option.
Customize a filter
The automatic filters are deliberately strict — one filter per field, targeting that field's column with default conditions. When you need more control, or a filter that isn't bound to a field at all, define a custom dynamic filter. There are two equivalent ways:
1. Turn the field's filterable option into a hash:
field :first_name,
as: :text,
filterable: true
filterable: {label: "Name", icon: "avo/font"} 2. Call dynamic_filter inside the resource's filters method — no field required:
def filters
dynamic_filter :first_name, label: "Name", icon: "avo/font"
endEvery option — label, icon, type, conditions, query, query_attributes, options, suggestions, and the rest — works as a key in the filterable hash and as a keyword argument to dynamic_filter alike. The API reference documents them all.
Filters order
Filters defined with dynamic_filter respect their definition order and render first in the list. Filters declared through the field's filterable option follow, sorted by label.
Custom filter IDs
When a dynamic_filter's ID doesn't match a database column, point it at the real column(s) with query_attributes:
# The filter ID is custom_population, but it should query the population column.
dynamic_filter :custom_population, query_attributes: :populationRestrict or rename the conditions
Pass a conditions hash to replace the type's default conditions, or an empty hash ({}) to hide the conditions dropdown entirely and always use the type's first default condition:
dynamic_filter :last_name,
type: :select,
conditions: {},
options: User.pluck(:last_name).compactWrite a custom query
By default, the chosen condition is applied to the filter's attribute via Ransack. Take over with the query option:
dynamic_filter :first_name,
conditions: {
case_sensitive: "Is (case sensitive)",
not_case_sensitive: "Is (case insensitive)"
}.invert,
query: -> {
case filter_param.condition.to_sym
when :case_sensitive
query.where("name = ?", filter_param.value)
when :not_case_sensitive
query.where("LOWER(name) = ?", filter_param.value.downcase)
end
}Inside the block you get query, filter_param (the filter's id, condition, and value), and everything on Avo::ExecutionContext.
Filter across multiple columns or associations
query_attributes accepts several columns — the condition applies to any of them (OR):
dynamic_filter :name,
type: :text,
query_attributes: [:first_name, :last_name]Prefix an attribute with the association name to filter through a belongs_to (a Ransack feature):
dynamic_filter label: "User (email & first_name)",
icon: "heroicons/solid/users",
query_attributes: [:user_email, :user_first_name]Offer suggestions or fetch values from an API
Text and tags filters can suggest values as the user types — a static array, a proc, or, for tags filters, a fetch_values_from URL that queries your own endpoint:
dynamic_filter :first_name, suggestions: ["Avo", "Cado"]See suggestions and fetch_values_from for the accepted shapes, and the endpoint contract for API-backed suggestions.
Apply instantly, without the Apply button
For select filters, combine apply_on_select and render_apply_button to filter as soon as the user picks a value:
dynamic_filter :category,
type: :select,
apply_on_select: true,
render_apply_button: falseHumanize the filter pills
Applied filters render as pills like "Is active is true". When raw values or auto-generated condition labels read poorly, override them with humanized_value and humanized_condition:
field :is_capital,
as: :boolean,
filterable: {
humanized_value: -> {
case filter.condition
when "is_true"
"yes"
when "is_false"
"no"
end
}
}Configure the filters bar
The bar itself is configured globally in the initializer — the toggle button's label, whether the bar starts expanded, and the URL param it reads from:
# config/initializers/avo.rb
Avo.configure do |config|
# Other Avo configurations
end
if defined?(Avo::DynamicFilters)
Avo::DynamicFilters.configure do |config|
config.button_label = "Advanced filters"
config.always_expanded = false
end
endSee button_label, always_expanded, and param_key in the API reference.
Caveats
At some point we'll integrate the basic filters into the dynamic filters bar. Until then, if a resource has both basic and dynamic filters and you've set always_expanded to false, you'll see two Filters buttons on the Index view. The default (always_expanded = true) avoids this, since the dynamic filters bar renders directly without its own toggle button.
Guides & Tutorials
-> How to filter associations
Learn how to effectively filter records based on their associations in Avo. This video tutorial demonstrates how to set up and use dynamic filters to query records through the attributes of their associations, enabling powerful and flexible data filtering capabilities.
belongs_to example
# app/avo/resources/post.rb
class Avo::Resources::Post < Avo::BaseResource
# Using field's filterable option
def fields
field :user,
as: :belongs_to,
filterable: {
label: "User (email & first_name)",
icon: "heroicons/solid/users",
query_attributes: [:user_email, :user_first_name]
}
end
# OR using dynamic_filter method
def filters
dynamic_filter label: "User (email & first_name)",
icon: "heroicons/solid/users",
query_attributes: [:user_email, :user_first_name]
end
endhas_many example
# app/avo/resources/author.rb
class Avo::Resources::Author < Avo::BaseResource
self.record_selector = false
def fields
field :preview, as: :preview
field :book_list, only_on: :preview do
tag.div do
tag.ul do
safe_join(
record.books.map do |book|
tag.li("#{book.title} (#{book.genre})")
end
)
end
end
end
field :name, filterable: true
# Filter the books by title and genre
field :books, as: :has_many, filterable: {
query_attributes: [:books_title, :books_genre]
}
end
end-> Composable filters
When you have multiple fields that require similar filtering logic, you can create reusable filter helpers to avoid code duplication. This is particularly useful when working with JSON columns, complex queries, or any scenario where multiple fields share the same filtering pattern.
This guide demonstrates four different approaches to create composable filters, each with its own benefits and use cases.
The problem: repetitive filter code
Before diving into the solutions, let's look at a common problem where filter logic is repeated across multiple fields:
# app/avo/resources/feedback.rb
class Avo::Resources::Feedback < Avo::BaseResource
def fields
field :company_size, filterable: {
type: :select,
options: -> { Feedback.pluck(Arel.sql("answers->>'company_size'")).uniq },
query: -> { query.where(Arel.sql("answers->>'company_size' = '#{filter_param.value}'")) }
}
field :company_industry, filterable: {
type: :select,
options: -> { Feedback.pluck(Arel.sql("answers->>'company_industry'")).uniq },
query: -> { query.where(Arel.sql("answers->>'company_industry' = '#{filter_param.value}'")) }
}
field :title, filterable: {
type: :select,
options: -> { Feedback.pluck(Arel.sql("answers->>'title'")).uniq },
query: -> { query.where(Arel.sql("answers->>'title' = '#{filter_param.value}'")) }
}
field :description, filterable: {
type: :select,
options: -> { Feedback.pluck(Arel.sql("answers->>'description'")).uniq },
query: -> { query.where(Arel.sql("answers->>'description' = '#{filter_param.value}'")) }
}
end
endAs you can see, the same filtering logic is repeated for each field, which violates the DRY (Don't Repeat Yourself) principle and makes the code harder to maintain.
Method 1: helper method with field configuration
This approach extracts the common filtering logic into a helper method that returns the filterable configuration hash. It's the most straightforward refactoring and maintains the existing field-based approach.
# app/avo/resources/feedback.rb
class Avo::Resources::Feedback < Avo::BaseResource
def filterable_helper(field_name)
{
type: :select,
options: -> { Feedback.pluck(Arel.sql("answers->>'#{field_name}'")).uniq },
query: -> { query.where(Arel.sql("answers->>'#{field_name}' = '#{filter_param.value}'")) }
}
end
def fields
field :company_size, filterable: filterable_helper(:company_size)
field :company_industry, filterable: filterable_helper(:company_industry)
field :title, filterable: filterable_helper(:title)
field :description, filterable: filterable_helper(:description)
end
endBenefits:
- Simple refactoring that maintains the existing field structure
- Easy to understand and implement
- Minimal changes to existing code
Best for: Quick refactoring of existing resources with repetitive filter logic.
WARNING
When you're using this approach within a with_options block, you need to allow the extra args that are passed to the helper method.
def filterable_helper(field_name, **args)
# ...
endMethod 2: separate fields and filters with helper
This approach separates the field definitions from the filter definitions using the dynamic_filter method. The helper method now directly creates the dynamic filter instead of returning a configuration hash.
# app/avo/resources/feedback.rb
class Avo::Resources::Feedback < Avo::BaseResource
def fields
field :company_size
field :company_industry
field :title
field :description
end
def filterable_helper(field_name)
dynamic_filter field_name,
type: :select,
options: -> { Feedback.pluck(Arel.sql("answers->>'#{field_name}'")).uniq },
query: -> { query.where(Arel.sql("answers->>'#{field_name}' = '#{filter_param.value}'")) }
end
def filters
filterable_helper(:company_size)
filterable_helper(:company_industry)
filterable_helper(:title)
filterable_helper(:description)
end
endBenefits:
- Clear separation between field definitions and filter logic
- Uses the more flexible
dynamic_filtermethod - Filters can be defined independently of fields
Best for:
- Resources where you want to maintain clean separation between display fields and filtering logic.
- Dynamic filters that are common across multiple resources.
Method 3: programmatic filter generation
This approach uses Ruby's array iteration to programmatically generate multiple filters with the same logic. It's the most concise and reduces the code to its essential elements.
# app/avo/resources/feedback.rb
class Avo::Resources::Feedback < Avo::BaseResource
def fields
field :company_size
field :company_industry
field :title
field :description
end
def filters
[:company_size, :company_industry, :title, :description].map do |field_name|
dynamic_filter field_name,
type: :select,
options: -> { Feedback.pluck(Arel.sql("answers->>'#{field_name}'")).uniq },
query: -> { query.where(Arel.sql("answers->>'#{field_name}' = '#{filter_param.value}'")) }
end
end
endBenefits:
- Most concise code
- Easy to add or remove filterable fields by modifying the array
- Clearly shows which fields share the same filtering logic
Best for: Resources with many fields that share identical filtering logic, especially when the list of filterable fields might change frequently.
Method 4: custom DSL with method override
This approach creates a custom DSL by overriding the field method to intercept a special symbol (:by_answer). This provides the cleanest syntax at the field level while hiding the complexity in the method override.
# app/avo/resources/feedback.rb
class Avo::Resources::Feedback < Avo::BaseResource
def fields
field :company_size, filterable: :by_answer
field :company_industry, filterable: :by_answer
field :title, filterable: :by_answer
field :description, filterable: :by_answer
end
def field(field_name, **args, &block)
if args[:filterable] == :by_answer
args[:filterable] = {
type: :select,
options: -> { Feedback.pluck(Arel.sql("answers->>'#{field_name}'")).uniq },
query: -> { query.where(Arel.sql("answers->>'#{field_name}' = '#{filter_param.value}'")) }
}
end
super(field_name, **args, &block)
end
endBenefits:
- Creates a clean, semantic DSL
- Hides complexity while maintaining readable field definitions
- Easy to extend with additional filter types
- Most maintainable for resources with many similar filterable fields
Best for: Resources where you want to create a custom, reusable filtering pattern that feels natural and integrated with Avo's field DSL.
Choosing the right approach
- Method 1 for quick refactoring of existing code
- Method 2 when you want clear separation between fields and filters
- Method 3 when you have many fields with identical filtering logic
- Method 4 when you want to create a clean, reusable DSL for your team
All approaches achieve the same goal of eliminating code duplication while providing different levels of abstraction and maintainability.