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.Resource to 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.

elixir
identity do
  name :tags
  route "/admin/dashboard/blog/tags"
  stream_name :tags
end

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

elixir
source do
  preload do
    always [:site, :version_count, :post_count]
    tenant [:language_variants]
    master master_language_variants: :language_variants
  end
end

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

elixir
columns do
  default_sort [{:inserted_at, :desc}]

  column :active do
    label fn -> dgettext("mishka_gervaz", "Active") end

    ui do
      type :badge
    end
  end
end

The source option changes where the value comes from:

  • source :field reads record.field.
  • source {:relation, :field} reads record.relation.field. Gervaz loads the relation for you. Declare it in preload only to load more, or to alias it.
  • source [:first_name, :last_name] joins the fields with separator, 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.

elixir
default_sort featured: :desc, inserted_at: :desc

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

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

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

OptionChangesUse it when
sourceWhich value is read.The value is on a relationship, or is several fields joined.
defaultThe value when the source is nil or an empty string.An empty cell should read as something.
formatThe 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 ... endHow the value is drawn, by a type module.A shape repeats, such as a badge, tags, bars or avatars.
renderThe 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:

elixir
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]"
  }
end

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

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

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

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

The 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