Filter a table
v0.0.1
Filters are the inputs above a Gervaz table. Each one narrows the query that loads the rows. This guide adds a search box, a select, a relation picker, a date range and a dependent filter, then groups them into panels. The examples come from the tags, redirects, media and workspaces tables of Mishka CMS.
Before you begin
- Have a
tablesection with anidentityand columns. See Build an admin table. - Know the filter types. A filter without a type is
:text. The others are:select,:boolean,:number,:date,:date_rangeand:relation. See Built-in types.
Add a search box
A :text filter is a text input. It matches part of a value without regard to case, and a % or _ in the term matches itself. The fields option makes one box search several fields:
filters do
filter :search, :text do
fields [:from_path, :to, :description]
ui do
label fn -> dgettext("mishka_gervaz", "Search") end
end
end
endA column's searchable option does not create search. Declare a text filter instead.
Add a select
A :select filter draws a dropdown. options takes a list of {label, value} pairs, or a zero-arity function that returns one. The function runs once, when the table mounts. ui.prompt is the empty choice.
filter :status, :select do
options fn -> Redirects.status_options() end
ui do
prompt dgettext_noop("mishka_gervaz", "Any status")
label fn -> dgettext("mishka_gervaz", "Status") end
end
end
filter :active, :booleanA :boolean filter is a checkbox. A filter line with no options needs no block.
Add a relation picker
A :relation filter chooses related records. mode sets how the options load:
:staticloads every option at once. It is the default, and suits small sets. The target's read action must not require pagination.:searchsearches as the reader types and picks one record.:search_multidoes the same and picks several.
The schema also accepts :load_more, but the built-in relation filter has no input for it and raises when it draws the filter. Use one of the three modes above.
The tags table filters by site with a multi-select search. restricted true hides it from users who are not masters:
filter :site_id, :relation do
mode :search_multi
display_field :name
search_field :name
min_chars 1
page_size 15
restricted true
ui do
label fn -> dgettext("mishka_gervaz", "Site") end
placeholder fn -> dgettext("mishka_gervaz", "Search sites...") end
end
endsearch_field is the field the typed text matches. min_chars is the number of characters before a search runs, and defaults to 2. page_size is the options per page in the paged modes, and defaults to 20. When display_field is a function of one argument, set search_field too, because there is no field to search otherwise. With a function of two arguments, the search falls back to the field :name.
Gervaz uses options on a relation filter only when no relationship and no resource is behind it. It then loads the list whole, searches it in memory by label or value, and gives string values. It is a searchable list for values the database does not hold as rows.
Add a date range
A :date_range filter draws two date inputs. Use source when the field has another name:
filter :created_at, :date_range do
source :inserted_at
ui do
label fn -> dgettext("mishka_gervaz", "Created at") end
end
endMake one filter depend on another
Set depends_on to the name of a parent filter. The child stays disabled until the parent has a value, except that a child whose parent is restricted is enabled for a reader who is not a master, because that reader never sees the parent. Its load function reads that value from state.filter_values. load scopes the options the child offers. ui.disabled_prompt is the text shown while it is disabled. The media table's folder filter depends on the site:
filter :media_category_id, :relation do
display_field fn folder, state ->
label = MishkaCmsCore.Runtime.MediaCategory.picker_label(folder)
if state.master_user?, do: "#{label} - #{folder.site.name}", else: label
end
search_field :name
mode :search
min_chars 1
page_size 15
depends_on :site_id
preload do
always [:site, :path_label]
end
load fn query, state ->
site_id =
if state.master_user? do
case Map.get(state.filter_values, :site_id) do
ids when is_list(ids) and ids != [] -> ids
id when is_binary(id) and id != "" -> id
_ -> nil
end
else
Map.get(state.current_user || %{}, :site_id)
end
case site_id do
nil -> query
ids when is_list(ids) -> Ash.Query.filter(query, site_id in ^ids)
id when is_binary(id) -> Ash.Query.filter(query, site_id == ^id)
end
end
ui do
label fn -> dgettext("mishka_gervaz", "Folder") end
disabled_prompt fn -> dgettext("mishka_gervaz", "Select Site first") end
end
endThe preload block loads the relationships that display_field reads. The parent must be a filter declared in the same table, or Gervaz reports Filters depend on non-existent filters when it compiles.
Choose how a filter narrows the query
These options act at different points. Read them as two halves: options, load, display_field and preload decide what the reader can pick. source, fields and apply decide what picking it does.
| Option | Acts on | Receives |
|---|---|---|
source | The main query | Names a database field that differs from the filter name. |
fields | The main query | Makes one text box search several fields. |
options | The input | A list, or a function that returns one. |
load | The option list | query, state. Returns the query. |
apply | The main query | query, value, context. Returns the query. |
display_field | The option label | The record, or the record and the state. |
Filter by a field the table does not have
A virtual filter has no database column. It names the resource its options come from and says, with apply, how the chosen value narrows the table query. The third argument of apply is a context map with path_params, current_user, master_user?, filter_values and archive_status. This is the collection filter of the blog posts table:
filter :collection_id, :relation do
virtual true
resource MishkaBlog.Collection
mode :search
display_field :name
search_field :name
min_chars 1
page_size 15
load fn query, state ->
case state.master_user? and Map.get(state.filter_values, :site_id) do
[_ | _] = site_ids -> Ash.Query.filter(query, site_id in ^site_ids)
_every_site -> query
end
end
apply fn query, value, context ->
relationship =
if context.master_user?, do: "master_collections", else: "tenant_collections"
Ash.Query.filter_input(query, %{
relationship => %{"id" => %{"in" => List.wrap(value)}}
})
end
ui do
label fn -> dgettext("mishka_gervaz", "Collection") end
end
endvirtual true records the intent only. What makes the filter work is resource, for the options, and apply, for the query. Without apply, the query falls back to filtering on source, which a virtual filter does not have. On a form field, apply has another shape, and Gervaz does not read it there.
Group the filters into panels
filter_groups organizes filters that already exist into collapsible panels. A group only names the filters, and a filter may sit in one group. Mishka Document's workspaces table keeps the search box in view and folds the rest:
filter_groups do
group :primary do
filters [:search]
end
group :advanced do
filters [:status, :featured, :site_id, :created_at]
collapsible true
ui do
label fn -> dgettext("mishka_gervaz", "Filter") end
icon "hero-funnel"
columns 4
end
end
endA group starts collapsed (collapsed true is the default), so set collapsible true when you want the reader to open it.

The redirects table declares a search, a relation, a select and a boolean filter.
The bar also shows two things the first three filters do not explain. The Site filter is the restricted relation filter from above, so only masters see it. The Active and Archived toggle belongs to the table's archive section, not to a filter.
Next steps
- Table filters DSL: every option of a filter and a group.
- Share table state and update it live: filter values in the URL.
- Write a custom type: a filter type of your own.