For my project ExVenture I wanted to have a very nice admin interface and with that comes filtering of tables. This is what I came up with, which I think has been very extendable and worked out well.
This is what we’ll end up with:
Web.Admin.ItemController
Let’s first start at a controller to see what it looks like there. The controller is incredibly simple, pulling out the filter parameters and passing it into a web specific module, Web.Item. You can view the full file here on github.
def index(conn, params) do
%{page: page, per: per} = conn.assigns
filter = Map.get(params, "item", %{})
%{page: items, pagination: pagination} = Item.all(filter: filter, page: page, per: per)
conn |> render("index.html", items: items, filter: filter, pagination: pagination)
end
This also includes my pagination module, but I will be glossing over that part. We will see it again in the Web.Item
module.
Web.Item
Next let’s look at the Web.Item module. This module contains the all/1
function used above. It implements the Filter’s behaviour that requires a single function filter_on_attribute/2
.
The map of filters passed in from the controller is looped over and passed into this function of the module. Inside the filter_on_attribute/2
function you can tweak the query to do whatever is needed to perform a filter based on that attributes.
defmodule Web.Item do
alias Data.Item
alias Web.Filter
@behaviour Filter
@doc """
Load all items
"""
@spec all(Keyword.t()) :: [Item.t()]
def all(opts \\ []) do
opts = Enum.into(opts, %{})
Item
|> order_by([i], i.id)
|> preload([:item_aspects])
|> Filter.filter(opts[:filter], __MODULE__)
|> Pagination.paginate(opts)
end
@impl Filter
def filter_on_attribute({"level_from", level}, query) do
query |> where([i], i.level >= ^level)
end
def filter_on_attribute({"level_to", level}, query) do
query |> where([i], i.level <= ^level)
end
def filter_on_attribute({"tag", value}, query) do
query |> where([n], fragment("? @> ?::varchar[]", n.tags, [^value]))
end
def filter_on_attribute(_, query), do: query
end
You can see here that I’m doing a fragment to look into an array of strings. You could also do a join or other more complicated query manipulation.
Web.Filter
Finally the Web.Filter module itself. This is very simple, it defines a callback and a single function. The function also requires the using module to be passed in as the last argument. This is very simple with the __MODULE__
macro.
defmodule Web.Filter do
@moduledoc """
Filter an `Ecto.Query` by a map of parameters. Modules
that use this should follow it's behaviour.
"""
@doc """
This will be reduced from the query params that are
passed into `filter/3`.
"""
@callback filter_on_attribute(
{attribute :: String.t(), value :: String.t()},
query :: Ecto.Query.t()
) :: Ecto.Query.t()
@doc """
Common elements of filtering a query
"""
@spec filter(Ecto.Query.t(), map(), atom()) :: Ecto.Query.t()
def filter(query, nil, _), do: query
def filter(query, filter, module) do
filter
|> Enum.reject(&(elem(&1, 1) == ""))
|> Enum.reduce(query, &module.filter_on_attribute/2)
end
end
There are two other small things that this does, it pattern matches on a nil map and simply passes the query through. Lastly it filters out values that are an empty string since that is an empty form field.
The Template
I won’t go into detail about this, but you can view it on github.
Conclusion
This has worked out really well in crafting my admin panel. Adding a new filter is as simple as adding the template code and a single function, no extra magic needed.