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,:tagsor:avatarscolumn may already draw what you need withui.extraoptions. - 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/2andbuild_query/4, and the built-in template draws the filter as a text input. For a custom field type it calls onlysanitize/2,parse_params/2andvalidate/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:
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: %{}
endThe 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:
column :id do
ui do
type MyApp.ColumnTypes.Short
extra %{prefix_length: 6}
end
endWrite 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:
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
endGervaz 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:
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
endIt 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}:
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
endThe 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:
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}
endUse 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
- Behaviours: the callbacks and arities of all four.
- React to table and form events with hooks: the
override_row_actionhook. - Built-in types: the models in
lib/mishka_gervaz.