Current section
Files
Jump to
Current section
Files
lib/cinder/filter_manager.ex
defmodule Cinder.FilterManager do
@moduledoc """
Coordinator for Cinder's modular filter system.
Acts as the main interface for filter operations while delegating
to individual filter type modules for specific implementations.
"""
use Phoenix.Component
alias Cinder.Filters.Registry
use Cinder.Messages
@type filter_type ::
:text
| :select
| :multi_select
| :multi_checkboxes
| :date_range
| :number_range
| :boolean
| :checkbox
@type filter_value ::
String.t()
| [String.t()]
| %{from: String.t(), to: String.t()}
| %{min: String.t(), max: String.t()}
@type filter :: %{type: filter_type(), value: filter_value(), operator: atom()}
@type filters :: %{String.t() => filter()}
@type column :: %{
field: String.t(),
label: String.t(),
filterable: boolean(),
filter_type: filter_type(),
filter_options: keyword()
}
@doc """
Renders filter controls for a list of columns.
## Parameters
- `columns` - List of column definitions
- `filters` - Current filter state map
- `theme` - Theme configuration
- `target` - LiveComponent target for events
## Returns
HEEx template for filter controls
"""
def render_filter_controls(assigns) do
filterable_columns = Enum.filter(assigns.columns, & &1.filterable)
active_filters = count_active_filters(assigns.filters)
filter_values = build_filter_values(filterable_columns, assigns.filters)
assigns =
assigns
|> assign(:filterable_columns, filterable_columns)
|> assign(:active_filters, active_filters)
|> assign(:filter_values, filter_values)
~H"""
<!-- Filter Controls (including search) -->
<div :if={@filterable_columns != [] or Map.get(assigns, :show_search, false)} class={@theme.filter_container_class} {@theme.filter_container_data}>
<!-- Filter Header -->
<div class={@theme.filter_header_class} {@theme.filter_header_data}>
<span class={@theme.filter_title_class} {@theme.filter_title_data}>
{@filters_label}
<span class={[@theme.filter_count_class, if(@active_filters == 0, do: "invisible", else: "")]} {@theme.filter_count_data}>
({@active_filters} {dngettext("cinder", "active", "active", @active_filters)})
</span>
</span>
<button
:if={@filterable_columns != []}
phx-click="clear_all_filters"
phx-target={@target}
class={[@theme.filter_clear_all_class, if(@active_filters == 0, do: "invisible", else: "")]}
{@theme.filter_clear_all_data}
>
{dgettext("cinder", "Clear all")}
</button>
</div>
<form phx-change="filter_change" phx-submit="filter_change" phx-target={@target}>
<div class={@theme.filter_inputs_class} {@theme.filter_inputs_data}>
<!-- Search Input (if enabled) - as first filter -->
<div :if={Map.get(assigns, :show_search, false)} class={@theme.filter_input_wrapper_class} {@theme.filter_input_wrapper_data}>
<label class={@theme.filter_label_class} {@theme.filter_label_data}>{Map.get(assigns, :search_label, "Search")}:</label>
<div class="flex items-center space-x-2">
<div class="flex-1 relative">
<input
type="text"
name="search"
value={Map.get(assigns, :search_term, "")}
placeholder={Map.get(assigns, :search_placeholder, "Search...")}
phx-debounce="300"
class={@theme.search_input_class}
{@theme.search_input_data}
/>
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<svg class={@theme.search_icon_class} {@theme.search_icon_data} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
<!-- Clear search button - consistent with filter clear buttons -->
<button
type="button"
phx-click="clear_filter"
phx-value-key="search"
phx-target={@target}
class={[
@theme.filter_clear_button_class,
unless(Map.get(assigns, :search_term, "") != "", do: "invisible", else: "")
]}
{@theme.filter_clear_button_data}
title="Clear search"
>
×
</button>
</div>
</div>
<div :for={column <- @filterable_columns} class={@theme.filter_input_wrapper_class} {@theme.filter_input_wrapper_data}>
<label class={[
@theme.filter_label_class,
if(column.filter_type == :checkbox, do: "invisible", else: "")
]} {@theme.filter_label_data}>{column.label}:</label>
<.filter_input
column={column}
current_value={Map.get(@filter_values, column.field, "")}
filter_values={@filter_values}
theme={@theme}
target={@target}
/>
</div>
</div>
</form>
</div>
"""
end
@doc """
Renders an individual filter input by delegating to the appropriate filter module.
"""
def filter_input(assigns) do
# Use enhanced registry that includes custom filters
filter_module = Registry.get_filter(assigns.column.filter_type)
filter_content =
if filter_module do
# Build additional assigns for filter modules
filter_assigns = %{
target: assigns.target,
filter_values: assigns.filter_values
}
# Delegate to the specific filter module with error handling
try do
filter_module.render(
assigns.column,
assigns.current_value,
assigns.theme,
filter_assigns
)
rescue
error ->
require Logger
Logger.warning(
"Error rendering custom filter :#{assigns.column.filter_type} for column '#{assigns.column.field}': #{inspect(error)}. " <>
"Falling back to text filter."
)
# Fallback to text filter if rendering fails
fallback_column = Map.put(assigns.column, :filter_type, :text)
text_module = Registry.get_filter(:text)
text_module.render(
fallback_column,
assigns.current_value,
assigns.theme,
filter_assigns
)
end
else
# Log warning for missing custom filter
if Registry.custom_filter?(assigns.column.filter_type) do
require Logger
Logger.warning(
"Custom filter :#{assigns.column.filter_type} is registered but module is not available. " <>
"Falling back to text filter for column '#{assigns.column.field}'"
)
end
# Fallback to text filter if type not found
fallback_column = Map.put(assigns.column, :filter_type, :text)
text_module = Registry.get_filter(:text)
filter_assigns = %{target: assigns.target, filter_values: assigns.filter_values}
text_module.render(fallback_column, assigns.current_value, assigns.theme, filter_assigns)
end
# Wrap with clear button
assigns = assign(assigns, :filter_content, filter_content)
~H"""
<div class="flex items-center space-x-2">
<div class="flex-1">
<%= @filter_content %>
</div>
<!-- Clear individual filter button - always present but invisible when no value -->
<button
type="button"
phx-click="clear_filter"
phx-value-key={@column.field}
phx-target={@target}
class={[
@theme.filter_clear_button_class,
unless(@current_value != "" and not is_nil(@current_value) and @current_value != [] and @current_value != %{from: "", to: ""} and @current_value != %{min: "", max: ""}, do: "invisible", else: "")
]}
{@theme.filter_clear_button_data}
title={dgettext("cinder", "Clear filter")}
>
×
</button>
</div>
"""
end
@doc """
Processes filter parameters from form submission using modular filter system.
"""
def process_filter_params(filter_params, columns) do
# Ensure multi-select fields are included even when no checkboxes are selected
complete_filter_params = Cinder.UrlManager.ensure_multiselect_fields(filter_params, columns)
# Handle special cases for range inputs (backward compatibility)
complete_filter_params
|> Enum.reduce(%{}, fn {key, value}, acc ->
cond do
# Handle date range fields
String.ends_with?(key, "_from") ->
base_key = String.replace_suffix(key, "_from", "")
to_key = base_key <> "_to"
to_value = Map.get(complete_filter_params, to_key, "")
combined_value =
if value != "" or to_value != "", do: "#{value},#{to_value}", else: ""
Map.put(acc, base_key, combined_value)
String.ends_with?(key, "_to") ->
# Skip _to keys as they're handled by _from keys
acc
# Handle number range fields
String.ends_with?(key, "_min") ->
base_key = String.replace_suffix(key, "_min", "")
max_key = base_key <> "_max"
max_value = Map.get(complete_filter_params, max_key, "")
combined_value =
if value != "" or max_value != "", do: "#{value},#{max_value}", else: ""
Map.put(acc, base_key, combined_value)
String.ends_with?(key, "_max") ->
# Skip _max keys as they're handled by _min keys
acc
true ->
Map.put(acc, key, value)
end
end)
end
@doc """
Transforms form values into structured filter objects using modular filter system.
"""
def params_to_filters(filter_params, columns) do
processed_params = process_filter_params(filter_params, columns)
columns
|> Enum.filter(& &1.filterable)
|> Enum.reduce(%{}, fn column, acc ->
raw_value = Map.get(processed_params, column.field, "")
# Special handling for checkbox filters: always process them,
# even when empty, so unchecking clears the filter
if column.filter_type == :checkbox do
case process_filter_value(raw_value, column) do
# When unchecked, exclude from filters (clears the filter)
nil -> acc
processed_filter -> Map.put(acc, column.field, processed_filter)
end
else
# Standard handling for other filter types
if has_filter_value?(raw_value) do
case process_filter_value(raw_value, column) do
nil -> acc
processed_filter -> Map.put(acc, column.field, processed_filter)
end
else
acc
end
end
end)
end
@doc """
Builds filter values map for form inputs using modular filter system.
"""
def build_filter_values(filterable_columns, filters) do
filterable_columns
|> Enum.reduce(%{}, fn column, acc ->
case Map.get(filters, column.field) do
nil ->
Map.put(acc, column.field, get_default_value(column.filter_type))
filter ->
formatted_value = format_filter_value(filter, column.filter_type)
Map.put(acc, column.field, formatted_value)
end
end)
end
@doc """
Clears a specific filter from the filter state.
"""
def clear_filter(filters, key) do
Map.delete(filters, key)
end
@doc """
Clears all filters from the filter state.
"""
def clear_all_filters(_filters) do
%{}
end
@doc """
Counts the number of active filters.
"""
def count_active_filters(filters) do
Enum.count(filters)
end
@doc """
Checks if a filter has a meaningful value using modular system.
"""
def has_filter_value?(value) do
Cinder.Filter.has_filter_value?(value)
end
@doc """
Validates all registered custom filters at application startup.
This function should be called during application initialization to ensure
all custom filters are properly implemented and available.
## Returns
:ok if all filters are valid, logs warnings for any issues
## Examples
# In your application.ex start/2 function
case Cinder.FilterManager.validate_runtime_filters() do
:ok -> :ok
{:error, _} -> :ok # Continue startup but log issues
end
"""
def validate_runtime_filters do
case Registry.validate_custom_filters() do
:ok ->
:ok
{:error, errors} ->
require Logger
Logger.warning(
"Custom filter validation failed during application startup:\n" <>
Enum.map_join(errors, "\n", &" - #{&1}")
)
{:error, errors}
end
end
@doc """
Infers filter configuration from Ash resource attribute definitions.
"""
def infer_filter_config(key, resource, slot) do
# Skip inference if filterable is false
if Map.get(slot, :filterable, false) do
attribute = get_ash_attribute(resource, key)
# Use explicit filter_type if provided, otherwise infer it
filter_type = Map.get(slot, :filter_type) || Registry.infer_filter_type(attribute, key)
# Validate custom filter exists at runtime
filter_type =
if Registry.custom_filter?(filter_type) do
# Check if the module actually exists and is loadable
module = Registry.get_filter(filter_type)
if module && Code.ensure_loaded?(module) do
filter_type
else
require Logger
Logger.warning(
"Custom filter :#{filter_type} is registered but module is not available " <>
"for column '#{key}'. Falling back to text filter."
)
:text
end
else
filter_type
end
default_options = Registry.default_options(filter_type, key)
slot_options = Map.get(slot, :filter_options, [])
# Merge slot options with defaults (slot options take precedence)
merged_options = Keyword.merge(default_options, slot_options)
# Enhance options with Ash-specific data for select/multi_select filters
enhanced_options =
case filter_type do
:select -> enhance_select_options(merged_options, attribute, key)
:multi_select -> enhance_select_options(merged_options, attribute, key)
:multi_checkboxes -> enhance_select_options(merged_options, attribute, key)
_ -> merged_options
end
%{filter_type: filter_type, filter_options: enhanced_options}
else
%{filter_type: :text, filter_options: []}
end
end
# Private helper functions
@doc """
Processes raw filter value using the appropriate filter module.
This function is public to enable comprehensive testing of custom filter processing.
"""
def process_filter_value(raw_value, column) do
# Use enhanced registry that includes custom filters
filter_module = Registry.get_filter(column.filter_type)
if filter_module do
try do
filter_module.process(raw_value, column)
rescue
error ->
require Logger
Logger.error(
"Error processing filter value for custom filter :#{column.filter_type} " <>
"on column '#{column.field}': #{inspect(error)}. Falling back to text processing."
)
# Fallback to text processing
text_module = Registry.get_filter(:text)
text_module.process(raw_value, column)
end
else
# Log warning for missing custom filter
if Registry.custom_filter?(column.filter_type) do
require Logger
Logger.warning(
"Custom filter :#{column.filter_type} is registered but module is not available. " <>
"Falling back to text processing for column '#{column.field}'"
)
end
# Fallback to text processing
text_module = Registry.get_filter(:text)
text_module.process(raw_value, column)
end
end
defp get_default_value(:date_range), do: %{from: "", to: ""}
defp get_default_value(:number_range), do: %{min: "", max: ""}
defp get_default_value(:multi_select), do: []
defp get_default_value(:multi_checkboxes), do: []
defp get_default_value(_), do: ""
defp format_filter_value(filter, filter_type) do
case {filter_type, filter} do
{:date_range, %{value: %{from: from, to: to}}} ->
%{from: from || "", to: to || ""}
{:number_range, %{value: %{min: min, max: max}}} ->
%{min: min || "", max: max || ""}
{:multi_select, %{value: values}} when is_list(values) ->
values
{:multi_checkboxes, %{value: values}} when is_list(values) ->
values
{:boolean, %{value: true}} ->
"true"
{:boolean, %{value: false}} ->
"false"
{_, %{value: value}} ->
value
_ ->
""
end
end
defp get_ash_attribute(resource, key) do
try do
# Handle embedded field notation by converting URL-safe format to bracket notation
converted_key = Cinder.Filter.Helpers.field_notation_from_url_safe(key)
# Parse the field notation to check if it's an embedded field
case Cinder.Filter.Helpers.parse_field_notation(converted_key) do
{:embedded, embed_field, field_name} ->
# Look up the embedded field attribute and then the nested field within it
get_embedded_attribute(resource, embed_field, field_name)
{:nested_embedded, embed_field, field_path} ->
# Handle nested embedded fields
get_nested_embedded_attribute(resource, embed_field, field_path)
{:relationship, rel_path, field_name} ->
# Handle relationship fields like "user.user_type" or "user.company.name"
get_relationship_attribute(resource, rel_path, field_name)
{:relationship_embedded, rel_path, embed_field, field_name} ->
# Handle relationship + embedded fields
get_relationship_embedded_attribute(resource, rel_path, embed_field, field_name)
{:relationship_nested_embedded, rel_path, embed_field, field_path} ->
# Handle relationship + nested embedded fields
get_relationship_nested_embedded_attribute(resource, rel_path, embed_field, field_path)
_ ->
# Regular field lookup
key_atom = if is_binary(key), do: String.to_atom(key), else: key
get_regular_attribute(resource, key_atom)
end
rescue
_ -> nil
catch
_ -> nil
end
end
defp get_regular_attribute(resource, key_atom) do
# Check if this is actually an Ash resource using Ash.Resource.Info.resource?/1
if Ash.Resource.Info.resource?(resource) do
# First check regular attributes
attributes = Ash.Resource.Info.attributes(resource)
attribute = Enum.find(attributes, &(&1.name == key_atom))
if attribute do
attribute
else
# Check aggregates - they should be treated as their type for filtering
aggregates = Ash.Resource.Info.aggregates(resource)
aggregate = Enum.find(aggregates, &(&1.name == key_atom))
if aggregate do
# Convert aggregate to attribute-like structure for type inference
# Count aggregates should be treated as integers
case aggregate.kind do
:count -> %{name: key_atom, type: :integer}
:sum -> %{name: key_atom, type: aggregate.type || :integer}
:avg -> %{name: key_atom, type: :decimal}
:max -> %{name: key_atom, type: aggregate.type || :integer}
:min -> %{name: key_atom, type: aggregate.type || :integer}
_ -> %{name: key_atom, type: :integer}
end
else
# Check calculations as well
calculations = Ash.Resource.Info.calculations(resource)
calculation = Enum.find(calculations, &(&1.name == key_atom))
if calculation do
# Convert calculation to attribute-like structure
%{name: key_atom, type: calculation.type}
else
nil
end
end
end
else
nil
end
end
defp get_embedded_attribute(resource, embed_field, field_name) do
if Ash.Resource.Info.resource?(resource) do
embed_atom = String.to_atom(embed_field)
field_atom = String.to_atom(field_name)
# Get the embedded resource attribute
attributes = Ash.Resource.Info.attributes(resource)
embed_attribute = Enum.find(attributes, &(&1.name == embed_atom))
if embed_attribute && embed_attribute.type do
# Get the embedded resource module
embedded_resource = embed_attribute.type
# Check if the embedded resource has the field
if Ash.Resource.Info.resource?(embedded_resource) do
embedded_attributes = Ash.Resource.Info.attributes(embedded_resource)
nested_attribute = Enum.find(embedded_attributes, &(&1.name == field_atom))
nested_attribute
else
nil
end
else
nil
end
else
nil
end
end
defp get_nested_embedded_attribute(resource, embed_field, field_path) do
if Ash.Resource.Info.resource?(resource) do
embed_atom = String.to_atom(embed_field)
# Get the embedded resource attribute
attributes = Ash.Resource.Info.attributes(resource)
embed_attribute = Enum.find(attributes, &(&1.name == embed_atom))
if embed_attribute && embed_attribute.type do
# Navigate through the nested path
traverse_embedded_path(embed_attribute.type, field_path)
else
nil
end
else
nil
end
end
defp get_relationship_embedded_attribute(resource, rel_path, embed_field, field_name) do
# Navigate to the related resource first
related_resource = traverse_relationship_path(resource, rel_path)
if related_resource do
get_embedded_attribute(related_resource, embed_field, field_name)
else
nil
end
end
defp get_relationship_nested_embedded_attribute(resource, rel_path, embed_field, field_path) do
# Navigate to the related resource first
related_resource = traverse_relationship_path(resource, rel_path)
if related_resource do
get_nested_embedded_attribute(related_resource, embed_field, field_path)
else
nil
end
end
defp traverse_embedded_path(current_resource, [field_name]) do
# Final field in the path
if Ash.Resource.Info.resource?(current_resource) do
field_atom = String.to_atom(field_name)
attributes = Ash.Resource.Info.attributes(current_resource)
Enum.find(attributes, &(&1.name == field_atom))
else
nil
end
end
defp traverse_embedded_path(current_resource, [field_name | rest]) do
# Navigate to the next embedded resource
if Ash.Resource.Info.resource?(current_resource) do
field_atom = String.to_atom(field_name)
attributes = Ash.Resource.Info.attributes(current_resource)
attribute = Enum.find(attributes, &(&1.name == field_atom))
if attribute && attribute.type do
traverse_embedded_path(attribute.type, rest)
else
nil
end
else
nil
end
end
defp get_relationship_attribute(resource, rel_path, field_name) do
# Navigate to the related resource first
related_resource = traverse_relationship_path(resource, rel_path)
if related_resource do
field_atom = String.to_atom(field_name)
get_regular_attribute(related_resource, field_atom)
else
nil
end
end
defp traverse_relationship_path(resource, [rel_name]) do
# Final relationship in the path
if Ash.Resource.Info.resource?(resource) do
rel_atom = String.to_atom(rel_name)
relationships = Ash.Resource.Info.relationships(resource)
relationship = Enum.find(relationships, &(&1.name == rel_atom))
if relationship do
relationship.destination
else
nil
end
else
nil
end
end
defp traverse_relationship_path(resource, [rel_name | rest]) do
# Navigate to the next related resource
if Ash.Resource.Info.resource?(resource) do
rel_atom = String.to_atom(rel_name)
relationships = Ash.Resource.Info.relationships(resource)
relationship = Enum.find(relationships, &(&1.name == rel_atom))
if relationship do
traverse_relationship_path(relationship.destination, rest)
else
nil
end
else
nil
end
end
defp enhance_select_options(default_options, attribute, key) do
case extract_enum_options(attribute) do
[] ->
# No enum options found, return defaults
# Convert URL-safe notation to bracket notation for proper humanization
converted_key = Cinder.Filter.Helpers.field_notation_from_url_safe(key)
humanized_key = Cinder.Filter.Helpers.humanize_embedded_field(converted_key)
Keyword.merge(default_options, prompt: "All #{humanized_key}")
options ->
# Add enum options and prompt
# Convert URL-safe notation to bracket notation for proper humanization
converted_key = Cinder.Filter.Helpers.field_notation_from_url_safe(key)
humanized_key = Cinder.Filter.Helpers.humanize_embedded_field(converted_key)
default_options
|> Keyword.put(:options, options)
|> Keyword.put(:prompt, "All #{humanized_key}")
end
end
@doc """
Extracts enum options from Ash resource attribute.
This function is public for testing purposes.
"""
def extract_enum_options(nil), do: []
def extract_enum_options(%{type: type, constraints: constraints}) do
cond do
# Handle constraint-based enums (new Ash format) - constraints can be a map or keyword list
(is_map(constraints) and Map.has_key?(constraints, :one_of)) or
(is_list(constraints) and Keyword.has_key?(constraints, :one_of)) ->
values =
if is_map(constraints),
do: Map.get(constraints, :one_of),
else: Keyword.get(constraints, :one_of)
enum_to_options(values, type)
# Handle Ash.Type.Enum and custom enum types
is_atom(type) ->
case (try do
apply(type, :values, [])
rescue
_ -> nil
end) do
values when is_list(values) -> enum_to_options(values, type)
_ -> []
end
true ->
[]
end
end
def extract_enum_options(%{type: {:one_of, values}}) when is_list(values) do
enum_to_options(values, nil)
end
def extract_enum_options(_), do: []
defp enum_to_options(values, enum_module) do
Enum.map(values, fn value ->
case value do
atom when is_atom(atom) ->
label =
if enum_module && function_exported?(enum_module, :label, 1) do
case apply(enum_module, :label, [atom]) do
nil -> Cinder.Filter.humanize_atom(atom)
label -> label
end
else
Cinder.Filter.humanize_atom(atom)
end
{label, atom}
string when is_binary(string) ->
{String.capitalize(string), string}
{value, label} ->
{to_string(label), value}
other ->
{to_string(other), other}
end
end)
end
end