FilterBuilder component for PhiaUI.
An advanced query-builder UI for constructing multi-condition filter rules at runtime. Each rule is a row containing:
- A field selector — which column/attribute to filter on
- An operator selector — how to compare (contains, equals, before, etc.)
- A value input — the comparison value (text, date, number, or select)
- A remove button — delete this rule
New rules are added via an "Add filter" button that fires a server event. The entire state is owned by the LiveView — no client-side JS is required.
When to use
Use FilterBuilder for power-user search and reporting interfaces where
users need to combine multiple conditions — like the filter panels in
Notion, Linear, Airtable, or an admin reporting dashboard.
For simple search + status dropdowns, use FilterBar instead.
Anatomy
| Component | Element | Purpose |
|---|---|---|
filter_builder/1 | div | Root container: rule list + "Add filter" button |
filter_rule/1 | div | One rule row: field / operator / value / remove |
Field schema
Each entry in the fields list is a map with:
| Key | Type | Required | Description |
|---|---|---|---|
:name | String.t() | yes | Unique field identifier |
:label | String.t() | yes | Display name in the field dropdown |
| :type | "text" | "select" | "date" | "number" | yes | Controls available operators and value input |
| :options | [{label, value}] | only for "select" | Options for the value dropdown |
Operator matrix
| Field type | Available operators |
|---|---|
text | contains, equals, starts with, ends with, is empty |
select | equals, not equals |
date | equals, before, after, between |
number | equals, greater than, less than, between |
Complete example — advanced search for a CRM contacts table
defp filter_fields do
[
%{name: "name", label: "Name", type: "text"},
%{name: "email", label: "Email", type: "text"},
%{name: "status", label: "Status", type: "select",
options: [{"Active", "active"}, {"Churned", "churned"}, {"Prospect", "prospect"}]},
%{name: "created_at", label: "Created", type: "date"},
%{name: "deal_value", label: "Deal Value", type: "number"}
]
end
# In the template:
<.filter_builder
fields={filter_fields()}
rules={@filter_rules}
on_add="add_filter"
on_remove="remove_filter"
on_change="update_filter"
/>LiveView state and handlers
# Default state in mount/3:
def mount(_params, _session, socket) do
{:ok, assign(socket, filter_rules: [])}
end
# Add a new empty rule (UUID id ensures stable DOM keys):
def handle_event("add_filter", _params, socket) do
rule = %{id: Ecto.UUID.generate(), field: "name", operator: "contains", value: ""}
{:noreply, update(socket, :filter_rules, &[&1 | [rule]])}
end
# Remove a specific rule by its id:
def handle_event("remove_filter", %{"id" => id}, socket) do
{:noreply, update(socket, :filter_rules, &Enum.reject(&1, fn r -> r.id == id end))}
end
# Update a specific rule field when user changes field/operator/value:
def handle_event("update_filter", params, socket) do
id = params["id"] || params["phx-value-id"]
rules = Enum.map(socket.assigns.filter_rules, fn rule ->
if rule.id == id do
%{rule |
field: params["filter"][id]["field"] || rule.field,
operator: params["filter"][id]["operator"] || rule.operator,
value: params["filter"][id]["value"] || rule.value
}
else
rule
end
end)
{:noreply, assign(socket, filter_rules: rules)}
end
# Apply rules to a query (example using Ecto):
defp apply_filters(query, rules) do
Enum.reduce(rules, query, fn
%{field: "name", operator: "contains", value: v}, q ->
from(r in q, where: ilike(r.name, ^"%#{v}%"))
%{field: "status", operator: "equals", value: v}, q ->
from(r in q, where: r.status == ^v)
_rule, q -> q
end)
end
Summary
Functions
Renders the filter builder container.
Renders existing rules as filter_rule/1 rows and an "Add filter" button
to append new rules. Entirely server-driven — no JS hook required.
All interactivity goes through LiveView phx-click / phx-change events.
Attributes
fields(:list) (required) - List of field definition maps. Each map must have:name,:label,:type, and optionally:options(fortype: "select").fields={[ %{name: "status", label: "Status", type: "select", options: [{"Active", "active"}, {"Inactive", "inactive"}]}, %{name: "created_at", label: "Created", type: "date"}, %{name: "amount", label: "Amount", type: "number"} ]}rules(:list) - List of active rule maps. Each map must have:id,:field,:operator, and:value. The:idmust be unique and stable across re-renders (useEcto.UUID.generate()when creating rules).rules={[ %{id: "abc-123", field: "status", operator: "equals", value: "active"}, %{id: "def-456", field: "created_at", operator: "after", value: "2026-01-01"} ]}Defaults to
[].on_add(:string) (required) -phx-clickevent name for the "Add filter" button. The handler should append a new default rule to the rules list.on_remove(:string) (required) -phx-clickevent name for the remove button on each rule row. The LiveView receives%{"id" => rule_id}.on_change(:string) (required) -phx-changeevent name fired when any field, operator, or value input changes. The LiveView receives%{"filter" => %{rule_id => %{"field" => ..., "operator" => ..., "value" => ...}}}.class(:string) - Additional CSS classes for the root wrapper. Defaults tonil.Global attributes are accepted. HTML attributes forwarded to the root div.
Renders a single filter rule row.
The row contains a field dropdown, an operator dropdown (populated based on the selected field's type), a value input (type-appropriate: text, date, number, or select), and a remove button.
The current_field is resolved at render time from the fields list so the
operator list and value input are always consistent with the selected field.
Attributes
id(:string) (required) - Unique rule identifier. Passed asphx-value-idon the remove button and embedded in inputnameattributes so the LiveView can identify which rule was changed:filter[rule_id][field],filter[rule_id][operator], etc.field(:string) (required) - Name of the currently selected field (must match a:namekey in thefieldslist).operator(:string) (required) - Currently selected operator string (e.g. "contains", "before", "equals").value(:string) - Current filter value string. Defaults to"".fields(:list) (required) - Samefieldslist passed tofilter_builder/1— used to render field options.on_remove(:string) (required) -phx-clickevent name for the remove button on this row.on_change(:string) (required) -phx-changeevent name fired when any input on this row changes.class(:string) - Additional CSS classes for the row wrapper. Defaults tonil.Global attributes are accepted. HTML attributes forwarded to the row div.