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 table section with an identity and 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_range and :relation. See Built-in types.

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.

elixir
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, :boolean

A :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:

  • :static loads every option at once. It is the default, and suits small sets. The target's read action must not require pagination.
  • :search searches as the reader types and picks one record.
  • :search_multi does 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:

elixir
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
end

search_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:

elixir
filter :created_at, :date_range do
  source :inserted_at

  ui do
    label fn -> dgettext("mishka_gervaz", "Created at") end
  end
end

Make 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:

elixir
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
end

The 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.

OptionActs onReceives
sourceThe main queryNames a database field that differs from the filter name.
fieldsThe main queryMakes one text box search several fields.
optionsThe inputA list, or a function that returns one.
loadThe option listquery, state. Returns the query.
applyThe main queryquery, value, context. Returns the query.
display_fieldThe option labelThe 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:

elixir
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
end

virtual 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:

elixir
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
end

A group starts collapsed (collapsed true is the default), so set collapsible true when you want the reader to open it.

the filter bar above the redirects table signed in as a master, with the Search box, the Site search select, the Status select with the prompt Any status, the Active checkbox, and the Active and Archived toggle

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