Write a custom type

v0.0.1

A type is a reusable piece for one kind of value. A column type draws a cell, an action type draws a row button, a filter type parses a value and builds a query, and a field type cleans, parses and validates a form value. Write one when the same render fn is about to be copied into a second resource. Keep it inline until then. This guide writes each of the four, using the built-in UUID column, Boolean filter and Hidden field types as models, and the Fork action of Mishka CMS.

Before you begin

  • Check Built-in types first. A :badge, :tags or :avatars column may already draw what you need with ui.extra options.
  • Know which callbacks run. Gervaz calls the drawing callback of a custom column type and a custom action type. For a custom filter type it calls only parse_value/2 and build_query/4, and the built-in template draws the filter as a text input. For a custom field type it calls only sanitize/2, parse_params/2 and validate/2, and the Standard form template draws the field as a text input.
  • Know that a type needs no registration. Pass the module wherever a type goes, and Gervaz returns an atom it does not know unchanged. For the same reason, Gervaz does not report a misspelled built-in name when it compiles. Check a name with MishkaGervaz.Table.Types.Column.builtin?/1.

Write a column type

A column type implements MishkaGervaz.Table.Behaviours.ColumnType. render/4 receives the value after format, the column config map, the full record, and the UI adapter module. Draw through the adapter so that the cell matches the design system. The built-in UUID type does this:

elixir
defmodule MishkaGervaz.Table.Types.Column.UUID do
  @behaviour MishkaGervaz.Table.Behaviours.ColumnType
  use Phoenix.Component

  @impl true
  def render(nil, _column, _record, ui), do: ui.cell_empty(%{__changed__: %{}})

  def render(value, column, _record, ui) do
    extra = get_extra(column)
    uuid_str = to_string(value)

    if extra[:show_full] do
      ui.cell_code(%{__changed__: %{}, value: uuid_str, class: extra[:class]})
    else
      prefix_len = extra[:prefix_length] || 8
      suffix_len = extra[:suffix_length] || 0
      display = truncate_uuid(uuid_str, prefix_len, suffix_len)
      ui.cell_code(%{__changed__: %{}, value: display, title: uuid_str, class: extra[:class]})
    end
  end

  defp get_extra(%{ui: %{extra: extra}}) when is_map(extra), do: extra
  defp get_extra(_), do: %{}
end

The private helper truncate_uuid/3 is left out above. A type takes its options from the column's ui.extra map. The behaviour also lists cell_class/1, and Gervaz does not call it. Use your own module like this:

elixir
column :id do
  ui do
    type MyApp.ColumnTypes.Short
    extra %{prefix_length: 6}
  end
end

Write a filter type

A filter type implements MishkaGervaz.Table.Behaviours.FilterType, which has three jobs: draw the input, parse the raw form value, and apply the parsed value to the query. Gervaz calls parse_value/2 and build_query/4 for any filter module. It does not call render_input/3 of a custom module, because the built-in table template draws a filter itself by its type name and draws any other as a text input. The built-in boolean filter implements all three:

elixir
defmodule MishkaGervaz.Table.Types.Filter.Boolean do
  @behaviour MishkaGervaz.Table.Behaviours.FilterType
  use Phoenix.Component
  import MishkaGervaz.Helpers, only: [to_boolean: 1, get_ui_label: 1]

  @impl true
  def render_input(filter, value, ui) do
    extra = filter[:ui][:extra] || %{}

    ui.checkbox(
      Map.merge(extra, %{
        __changed__: %{},
        name: filter.name,
        value: "true",
        checked: to_boolean(value) == true,
        label: get_ui_label(filter),
        icon: filter[:ui][:icon],
        variant: :filter
      })
    )
  end

  @impl true
  def parse_value(value, _filter), do: to_boolean(value)

  @impl true
  def build_query(query, field, value, _filter \\ %{})

  def build_query(query, field, true, _filter) do
    Ash.Query.filter_input(query, %{field => %{eq: true}})
  end

  def build_query(query, field, false, _filter) do
    Ash.Query.filter_input(query, %{field => %{eq: false}})
  end

  def build_query(query, _field, _value, _filter), do: query
end

Gervaz only calls build_query/4, so implement the four-argument function. A default for the last argument, as above, defines the three-argument one too. Use the filter config in the fourth argument when you need it, for example to search several fields. Cover the empty value by returning the query unchanged. Use the module as the second argument: filter :created, MyApp.FilterTypes.DateRange.

Write an action type

An action type implements MishkaGervaz.Table.Behaviours.ActionType. render/5 receives the assigns of the table, the action config, the record, the UI adapter and the phx-target. Build a fresh map for the component and render it with dynamic_component/1. Do not pass the incoming assigns through: the component declares attr :rest, :global, so every key it does not recognize becomes an HTML attribute, and the whole table state would land in the markup.

Three kinds of key belong in the map. The first is :module and :function, which dynamic_component/1 removes before it calls the adapter. The second is the attributes the component declares, such as :label, :icon, :class and :variant. The third is bindings named phx_* and data_*, which Gervaz turns into phx-click, phx-value-id, phx-target and data-confirm. Mishka CMS uses a custom type for the Fork button of its drafts:

elixir
defmodule MishkaCmsCoreWeb.TableActions.Fork do
  @behaviour MishkaGervaz.Table.Behaviours.ActionType

  use Phoenix.Component
  use MishkaGervaz.Messages

  import MishkaGervaz.Helpers, only: [dynamic_component: 1, maybe_assign: 3, resolve_label: 1]

  @impl true
  def render(_assigns, action, record, ui, target) do
    assigns =
      %{__changed__: %{}}
      |> assign(:module, ui)
      |> assign(:function, :button)
      |> assign(:variant, :secondary)
      |> assign(:label, resolve_label(action[:ui][:label]) || dgettext("mishka_gervaz", "Copy"))
      |> assign(:icon, action[:ui][:icon] || "hero-document-duplicate")
      |> maybe_assign(:class, action[:ui][:class])
      |> assign(:phx_click, "row_action")
      |> assign(:phx_value_event, "fork")
      |> assign(:phx_value_id, record.id)
      |> assign(:phx_target, target)
      |> maybe_assign(:data_confirm, action[:confirm])

    ~H"""
    <.dynamic_component {assigns} />
    """
  end
end

It pushes Gervaz's own row_action event with the event name "fork". What forking means belongs to a hook of the resource, not to the type. Declare the action, and answer the event with override_row_action. The key of that hook is the event string, which here equals the action name. An event that no hook answers reaches the parent LiveView as {:row_action, "fork", payload}:

elixir
row_actions do
  action :fork, type: MishkaCmsCoreWeb.TableActions.Fork
end

hooks do
  override_row_action :fork, fn payload, state ->
    send(self(), {:draft_row_action, "fork_draft", payload["id"]})
    {:ok, state}
  end
end

The table's before_row_action and after_row_action hooks do not run for a custom type. The Fork type above passes action[:confirm] to data_confirm as written, so give it a string. Read the label with MishkaGervaz.Helpers.resolve_label/1 or action_label/1, and resolve a confirm message with resolve_confirm/2 when it may be a function, as the built-in types do. The incoming assigns[:state] holds the table state, for example master_user?.

Write a field type

A field type implements MishkaGervaz.Form.Behaviours.FieldType. The behaviour requires render/2, but the Standard form template does not call it, and it draws a field with a module type as a text input. Gervaz calls sanitize/2 before validation, parse_params/2 to turn the raw value into a typed one, and validate/2, which returns {:ok, value} or {:error, message}. It does not call default_ui/0. The built-in hidden type is the smallest model:

elixir
defmodule MishkaGervaz.Form.Types.Field.Hidden do
  @behaviour MishkaGervaz.Form.Behaviours.FieldType

  @impl true
  def render(assigns, _config), do: assigns

  @impl true
  def parse_params(value, _config), do: value

  @impl true
  def default_ui, do: %{type: :hidden}
end

Use the module as the field type: field :background_color, MyApp.FieldTypes.Color. The text types strip HTML in sanitize/2. A custom type that takes free text and skips it opts out of that protection.

Next steps