Build an admin table
v0.0.1
The table section of the mishka_gervaz block turns an Ash resource into an admin list. This guide names the table, sets its data source, declares its columns, sorts them and draws their cells. It uses the tags table and the redirects table of Mishka CMS as examples. For filters, row actions and live updates, see the guides linked at the end.
Before you begin
- Add
MishkaGervaz.Resourceto the resource's extensions, and put the table defaults on the domain. See Get Started. - Make sure the resource has the Ash actions your table will name. A table always needs a read action.
Name the table
The identity section names the table and gives it a base route. Both name and route are required as soon as the table has a column, a filter, a row action or a bulk action.
identity do
name :tags
route "/admin/dashboard/blog/tags"
stream_name :tags
endstream_name is optional. It defaults to the name plus _stream, so name :tags gives :tags_stream. At run time the table adds its component id in front, so read the name from state.static.stream_name.
A route can hold params, such as "/admin/dashboard/document/sections/:workspace_version_id". A param is available as state.path_params once the page passes the url_state of UrlSync.decode/3, which needs a url_sync section. A param whose name matches an attribute of the resource also becomes a filter that the reader cannot clear. To pass a param that must filter nothing, give it a name that is not an attribute.
Choose the actions and preloads
The source section tells the table which Ash actions to run and which relationships to load. Put the actions on the domain so that every resource inherits them. A resource overrides a key when it needs another action.
source do
preload do
always [:site, :version_count, :post_count]
tenant [:language_variants]
master master_language_variants: :language_variants
end
endA preload entry is an atom, or a {source, alias} pair such as master_language_variants: :language_variants. With the pair, a master user's table loads master_language_variants and the column reads it as language_variants, the same name a tenant user's table uses.
Declare the columns
A column names a value. By default, the name is the resource field that holds it. The redirects table sets its default sort and declares one plain column, :active, beside its computed ones:
columns do
default_sort [{:inserted_at, :desc}]
column :active do
label fn -> dgettext("mishka_gervaz", "Active") end
ui do
type :badge
end
end
endThe source option changes where the value comes from:
source :fieldreadsrecord.field.source {:relation, :field}readsrecord.relation.field. Gervaz loads the relation for you. Declare it inpreloadonly to load more, or to alias it.source [:first_name, :last_name]joins the fields withseparator, which defaults to a space.source [{:user, :name}, :title]mixes nested and direct fields.
column_order lists the order of the columns, and a column not in the list goes last. position puts one column in place with an integer, :first or :last. {:before, name} and {:after, name} are accepted, but Gervaz does not use the name: it puts the column half a slot before or after its own place in the declaration. Gervaz ignores position when column_order is set.
Sort the table
Set sortable true on a column to let the reader sort by it. default_sort sets the order of a fresh load. It must be a list of {field, :asc | :desc} tuples. A keyword list works too. A value that is not a list, such as a bare atom or a single tuple, is ignored, and the table falls back to the rule below.
default_sort featured: :desc, inserted_at: :descWithout default_sort, a sortable :inserted_at column implies newest first. Otherwise the table has no default order.
Add a computed column
A column with no database field of its own is static. It must say which fields it reads with requires, and which database fields it sorts by with sort_field if it is also sortable. Its render function receives a map of exactly the required fields. This is the tag name column:
column :name do
static true
requires [:id, :name, :slug]
label fn -> dgettext("mishka_gervaz", "Name") end
sortable true
sort_field [:name]
ui do
width "minmax(170px,1.2fr)"
end
render fn record ->
assigns = %{id: record.id, name: record.name, slug: record.slug}
~H"""
<div class="min-w-0">
<div class="truncate text-[13.5px] font-semibold text-[#17161a]">{@name}</div>
<div class="mt-[2px] flex items-center gap-[5px]">
<span class="truncate font-['Space_Grotesk'] text-[11px] font-medium text-[#a8a5a0]">
/{@slug}
</span>
<.copy_button id={"tag-slug-copy-#{@id}"} value={@slug} label={gettext("Copy slug")} />
</div>
</div>
"""
end
endA function in render returns HEEx, so the module needs import Phoenix.Component, only: [sigil_H: 2]. The copy_button component comes from the UI adapter module, imported with import MishkaGervaz.UIAdapters.Tailwind, only: [copy_button: 1]. With a second argument, fn record, state -> ... end, the function also gets the table state. The tags table reads state.master_user? there to show the site name to masters only.
Choose how a cell is drawn
Five options change what a cell shows. They apply in this order, so more than one can apply to a column.
| Option | Changes | Use it when |
|---|---|---|
source | Which value is read. | The value is on a relationship, or is several fields joined. |
default | The value when the source is nil or an empty string. | An empty cell should read as something. |
format | The value before it is drawn. fn value -> ... end or fn state, record, value -> ... end. | The change is a pure transform, such as bytes to a size. |
ui do type ... end | How the value is drawn, by a type module. | A shape repeats, such as a badge, tags, bars or avatars. |
render | The whole cell. | One cell is bespoke. |
At draw time, render wins over ui.type, which wins over the plain value, and format runs before all three. Reach for format before render when the change is text, and for a type before render when the shape repeats. A render is markup you now maintain.
The redirects table draws its :active column with the :badge type. The colors, labels and variant go in ui.extra:
ui do
type :badge
extra %{
variant: :pill,
colors: %{
"true" => "bg-[#e6f6ee] text-[#177a53]",
"false" => "bg-[#f2f1ec] text-[#8a877f]"
},
labels: %{
"true" => dgettext_noop("mishka_gervaz", "Active"),
"false" => dgettext_noop("mishka_gervaz", "Inactive")
},
default_color: "bg-[#f2f1ec] text-[#8a877f]"
}
endEach type reads its own keys. See Built-in types for the list. The schema also accepts :currency, :percentage, :time, :image, :avatar, :progress, :json and :custom, but there is no built-in renderer for them. Pair one with your own render, or give type a module.
Discover columns from attributes
For a table with many plain attributes, auto_columns builds the columns from the attributes of the resource, public or not, and merges them with the ones you declare. The Mishka Document workspaces table names the attributes it draws by hand and lets the rest be discovered:
columns do
default_sort featured: :desc, inserted_at: :desc
auto_columns do
except [
:id,
:name,
:slug,
:description,
:status,
:schema,
:seo_tags,
:permissions,
:featured,
:display_order,
:language,
:language_group_id,
:inserted_at,
:updated_at,
:archived_at,
:site_id
]
end
endonly keeps only the named attributes, and position :start puts the discovered columns first. defaults, ui_defaults and override blocks set their options. See Table columns DSL.
Show a column to some readers
visible takes a boolean or a function of the table state. The tags table draws its site and date column only in the plain table template:
column :site_datetime do
static true
requires [:site_id, :site, :inserted_at]
label fn -> dgettext("mishka_gervaz", "Site / Date") end
visible fn state -> state.template.name() == :table end
endThe redirects table shows its site column to masters only, with visible fn state -> state.master_user? end. Read only the state fields that How Gervaz works lists.
Next steps
- Filter a table: search, select and relation filters.
- Add row and bulk actions: edit, archive and restore.
- Table columns DSL: every option of a column.
- Translate labels and messages: the
dgettextcalls in these examples.