Current section

Files

Jump to
cinder lib cinder filter.ex
Raw

lib/cinder/filter.ex

defmodule Cinder.Filter do
@moduledoc """
Base behavior for Cinder filter implementations.
Defines the common interface that all filter types must implement,
along with shared types and utility functions.
## Quick Start
The most convenient way to create a custom filter is to use this module:
defmodule MyApp.Filters.Slider do
use Cinder.Filter
@impl true
def render(column, current_value, theme, assigns) do
filter_options = Map.get(column, :filter_options, [])
min_value = get_option(filter_options, :min, 0)
max_value = get_option(filter_options, :max, 100)
current = current_value || min_value
assigns = %{
column: column,
current_value: current,
min_value: min_value,
max_value: max_value,
theme: theme
}
~H\"\"\"
<div class="flex flex-col space-y-2">
<input
type="range"
name={field_name(@column.field)}
value={@current_value}
min={@min_value}
max={@max_value}
phx-debounce="100"
class={Map.get(@theme, :filter_slider_input_class, "w-full")}
/>
<output>{@current_value}</output>
</div>
\"\"\"
end
@impl true
def process(raw_value, column) when is_binary(raw_value) do
case Integer.parse(raw_value) do
{value, ""} ->
%{
type: :slider,
value: value,
operator: :less_than_or_equal
}
_ -> nil
end
end
def process(_raw_value, _column), do: nil
@impl true
def validate(%{type: :slider, value: value, operator: operator})
when is_integer(value) and is_atom(operator), do: true
def validate(_), do: false
@impl true
def default_options, do: [min: 0, max: 100, step: 1]
@impl true
def empty?(value) do
case value do
nil -> true
%{value: nil} -> true
_ -> false
end
end
@impl true
def build_query(query, field, filter_value) do
%{value: value} = filter_value
field_atom = String.to_atom(field)
Ash.Query.filter(query, ^ref(field_atom) <= ^value)
end
end
Then register it in your configuration:
config :cinder, :filters, %{
slider: MyApp.Filters.Slider
}
And call `Cinder.setup()` in your application start function to register
all configured filters.
This automatically:
- Adds the `@behaviour Cinder.Filter` declaration
- Imports Phoenix.Component for HEEx templates
- Imports helper functions from this module
## Required Callbacks
All custom filters must implement these callbacks:
### render/4
Renders the filter UI component.
@callback render(column :: map(), current_value :: any(), theme :: map(), assigns :: map()) :: Phoenix.LiveView.Rendered.t()
- `column`: Column configuration with `field`, `label`, `filter_options`
- `current_value`: Current filter value (nil if no filter applied)
- `theme`: Theme configuration with CSS classes
- `assigns`: Additional assigns from the parent component
### process/2
Processes raw form/URL input into structured filter data.
@callback process(raw_value :: any(), column :: map()) :: map() | nil
Must return a map with `:type`, `:value`, `:operator` keys, or `nil`.
### validate/1
Validates a processed filter value.
@callback validate(value :: any()) :: boolean()
### default_options/0
Returns default configuration options.
@callback default_options() :: keyword()
### empty?/1
Determines if a filter value is "empty" (no filtering applied).
@callback empty?(value :: any()) :: boolean()
### build_query/3
**Critical for functionality!** Builds the Ash query for this filter.
@callback build_query(query :: Ash.Query.t(), field :: String.t(), filter_value :: map()) :: Ash.Query.t()
## Helper Functions
### field_name/1
Generates proper form field names for filter inputs:
~H\"\"\"
<input name={field_name(@column.field)} ... />
\"\"\"
### get_option/3
Safely extracts options from filter configuration with defaults:
filter_options = Map.get(column, :filter_options, [])
placeholder = get_option(filter_options, :placeholder, "Enter text...")
## Query Building Patterns
### Recommended Approach: Use the Centralized Helper
def build_query(query, field, filter_value) do
%{value: value, operator: operator} = filter_value
# Use the centralized helper which supports direct, relationship, and embedded fields
Cinder.Filter.Helpers.build_ash_filter(query, field, value, operator)
end
### Basic Field Filtering
def build_query(query, field, filter_value) do
%{value: value} = filter_value
field_atom = String.to_atom(field)
Ash.Query.filter(query, ^ref(field_atom) == ^value)
end
### Relationship Filtering
Handle dot notation fields like "user.name":
def build_query(query, field, filter_value) do
%{value: value} = filter_value
if String.contains?(field, ".") do
path_atoms = field |> String.split(".") |> Enum.map(&String.to_atom/1)
{rel_path, [field_atom]} = Enum.split(path_atoms, -1)
Ash.Query.filter(query, exists(^rel_path, ^ref(field_atom) == ^value))
else
field_atom = String.to_atom(field)
Ash.Query.filter(query, ^ref(field_atom) == ^value)
end
end
### Embedded Field Filtering
Handle bracket notation fields like "profile[:first_name]":
def build_query(query, field, filter_value) do
%{value: value} = filter_value
# The centralized helper automatically detects and handles embedded fields
Cinder.Filter.Helpers.build_ash_filter(query, field, value, :equals)
end
Supported embedded field notations:
- Basic embedded: `profile[:first_name]`
- Nested embedded: `settings[:address][:street]`
- Mixed relationship + embedded: `user.profile[:first_name]`
- Complex mixed: `company.settings[:address][:city]`
## Best Practices
1. **Always implement build_query/3** - This is what actually filters data
2. **Handle edge cases in process/2** - Return nil for invalid input
3. **Validate filter values** - Check structure and data types
4. **Document your filters** - Include usage examples and options
"""
@doc false
defmacro __using__(_opts) do
quote do
@behaviour Cinder.Filter
use Phoenix.Component
require Ash.Query
import Ash.Expr
import Cinder.Filter
end
end
@typedoc "Raw filter value from form/URL input"
@type raw_filter_value :: any()
@typedoc "Processed filter value returned by process/2 and passed to build_query/3"
@type processed_filter_value :: map() | nil
@typedoc "Filter options keyword list"
@type filter_options :: keyword()
@typedoc "Column configuration map"
@type column :: map()
@typedoc "Theme configuration map"
@type theme :: map()
@doc """
Renders the filter input component for this filter type.
## Parameters
- `column` - Column definition with filter configuration
- `current_value` - Current filter value (processed or nil)
- `theme` - Theme configuration for styling
- `assigns` - Additional assigns (target, filter_values, etc.)
## Returns
HEEx template for the filter input
"""
@callback render(column(), processed_filter_value(), theme(), map()) ::
Phoenix.LiveView.Rendered.t()
@doc """
Processes raw form input into structured filter value.
## Parameters
- `raw_value` - Raw value from form submission
- `column` - Column definition with filter configuration
## Returns
Structured filter value map or nil if invalid
"""
@callback process(raw_filter_value(), column()) :: processed_filter_value()
@doc """
Validates a filter value for this filter type.
## Parameters
- `value` - Filter value to validate
## Returns
Boolean indicating if value is valid
"""
@callback validate(processed_filter_value()) :: boolean()
@doc """
Returns default options for this filter type.
## Returns
Keyword list of default filter options
"""
@callback default_options() :: filter_options()
@doc """
Checks if a filter value is considered empty/inactive.
## Parameters
- `value` - Filter value to check
## Returns
Boolean indicating if the filter should be considered inactive
"""
@callback empty?(processed_filter_value()) :: boolean()
@doc """
Builds query filters for this filter type.
## Parameters
- `query` - The Ash query to modify
- `field` - The field name being filtered
- `filter_value` - The processed filter value (map with :value, :type, etc.)
## Returns
Modified Ash query with the filter applied
"""
@callback build_query(Ash.Query.t(), String.t(), map()) :: Ash.Query.t()
# Shared utility functions
@doc """
Checks if a filter has a meaningful value across all filter types.
"""
def has_filter_value?(value) do
case value do
"" -> false
nil -> false
[] -> false
%{from: "", to: ""} -> false
%{min: "", max: ""} -> false
%{from: nil, to: nil} -> false
%{min: nil, max: nil} -> false
_ -> true
end
end
@doc """
Converts a key to human readable string.
"""
def humanize_key(key) do
key
|> to_string()
|> String.replace("_", " ")
|> String.split(" ")
|> Enum.map_join(" ", &String.capitalize/1)
end
@doc """
Converts an atom to human readable string.
"""
def humanize_atom(atom) do
atom
|> to_string()
|> String.replace("_", " ")
|> String.split(" ")
|> Enum.map_join(" ", &String.capitalize/1)
end
@doc """
Gets a nested value from filter options with a default.
"""
def get_option(filter_options, path, default \\ nil) do
case get_in(filter_options, List.wrap(path)) do
nil -> default
value -> value
end
end
@doc """
Merges default options with provided options.
"""
def merge_options(defaults, provided) when is_list(defaults) and is_list(provided) do
Keyword.merge(defaults, provided)
end
def merge_options(defaults, _provided) when is_list(defaults) do
defaults
end
@doc """
Generates a form field name for the given column key.
"""
def field_name(column_key, suffix \\ nil) do
case suffix do
nil -> "filters[#{column_key}]"
suffix -> "filters[#{column_key}_#{suffix}]"
end
end
@doc """
Sanitizes a field name for use in HTML attributes and CSS selectors.
Replaces all characters that are invalid in CSS selectors with underscores,
keeping only letters, numbers, hyphens, and underscores.
## Examples
iex> Cinder.Filter.sanitized_field_name("main_unit?")
"main_unit_"
iex> Cinder.Filter.sanitized_field_name("user.name")
"user_name"
iex> Cinder.Filter.sanitized_field_name("profile[:first_name]")
"profile__first_name_"
"""
def sanitized_field_name(field_name) do
field_name
|> String.replace(~r/[^a-zA-Z0-9_-]/, "_")
end
@doc """
Generates a unique HTML ID for a filter input.
When a table_id is provided, it's used as a prefix to ensure uniqueness
when multiple tables exist on the same page.
## Examples
iex> Cinder.Filter.filter_id("my-table", "name")
"my-table-filter-name"
iex> Cinder.Filter.filter_id("users", "user.email")
"users-filter-user_email"
iex> Cinder.Filter.filter_id("products", "price", "min")
"products-filter-price-min"
"""
def filter_id(table_id, field), do: "#{table_id}-filter-#{sanitized_field_name(field)}"
def filter_id(table_id, field, suffix),
do: "#{table_id}-filter-#{sanitized_field_name(field)}-#{suffix}"
end