Change how the admin looks

v0.0.1

Gervaz draws with a UI adapter, a module of 69 component functions such as button/1, text_input/1 and badge/1. The default adapter draws plain Tailwind CSS. This guide changes how a table and a form look, from a single class in the DSL up to an adapter of your own. The examples come from the layouts table, and from the Review and Admin adapters of Mishka CMS. The Dynamic adapter example comes from the Gervaz module documentation.

Before you begin

  • Know the split between the two layers. A template decides where things go. An adapter decides how each piece looks. Swapping one never forces the other. See How Gervaz works.
  • Use the lowest layer that solves the problem. A class in the DSL costs the least, and an adapter costs more to maintain.

Set a class where the element is declared

Most entities have a ui block with class options. A column has class for its cells and header_class for its header, and also width. A form group has class. A row action has class. A form field also has class and wrapper_class, but the Standard form template does not read them.

A row gets a class per record with row class. List every class that apply can return in possible. Tailwind scans source files, not run-time values, so listing the literals keeps them in the build. The layouts table tints the rows of global layouts:

elixir
row do
  selectable true

  class do
    possible ["bg-[#f6f4fd]"]
    apply fn record -> if is_nil(record.site_id), do: "bg-[#f6f4fd]" end
  end
end

The presentation section has a theme block. The plain table template reads two of its keys, header_class and row_class. The built-in form template does not read the form theme keys, so use the ui blocks of the fields, groups and buttons for a form. Domain-level theme blocks set the same table keys for every table.

Choose the adapter

Set the adapter once on the domain, and every resource inherits it. A resource names its own in the presentation section when it differs:

elixir
presentation do
  template MishkaBlogWeb.Templates.TagCard
  ui_adapter MishkaCmsCoreWeb.UIAdapters.Admin
end

One adapter serves both the table and the form. The form-side presentation section takes the same ui_adapter option. Name the adapter in any resource that writes a presentation block, so the choice does not depend on inheritance.

Write an adapter that changes some components

use MishkaGervaz.Behaviours.UIAdapter writes a defdelegate to a fallback module for each of the 69 functions and marks them overridable. An adapter that defines nothing behaves like its fallback, and an adapter that defines button/1 changes buttons and nothing else. The fallback defaults to MishkaGervaz.UIAdapters.Tailwind, and it can be another adapter.

The Review adapter of Mishka CMS restyles five components for a 360px sidebar, badge/1, textarea/1, select/1, button/1 and empty_state/1, and leaves the rest to Tailwind. This is its button/1, shortened:

elixir
defmodule MishkaCmsCoreWeb.UIAdapters.Review do
  use MishkaGervaz.Behaviours.UIAdapter,
    fallback: MishkaGervaz.UIAdapters.Tailwind

  attr :label, :string, default: nil
  attr :class, :string, default: nil
  attr :type, :string, default: "button"
  attr :icon, :string, default: nil
  attr :variant, :atom, default: :default

  attr :rest, :global,
    include:
      ~w(phx-click phx-target phx-value-id phx-value-event phx-value-values data-confirm disabled)

  def button(assigns) do
    ~H"""
    <button type={@type} class={@class} title={@label} {@rest}>
      <span :if={@label not in [nil, ""]} class="lbl">{@label}</span>
    </button>
    """
  end
end

Three habits keep an adapter from fighting the DSL:

  • Honor a class that the caller passes. The DSL's ui do class "..." end must win, or the adapter becomes a wall the resource cannot get past.
  • Always put the label in the DOM, even when the design hides it. A template or a class such as [&_.lbl]:hidden may decide to hide it.
  • Declare attr :rest, :global with the phx-* and data-confirm attributes you need, so events reach the element.

The full list of functions is in Behaviours. MishkaGervaz.Behaviours.UIAdapter.component_functions/0 returns it at run time.

Read a field's extra options in an adapter

The form template passes the extra map of the field's ui block to the adapter component as an assign. The Admin adapter of Mishka CMS draws a rich-text editor for a textarea whose extra says so, and a code editor for one with a language:

elixir
field :content, :textarea do
  ui do
    label fn -> dgettext("mishka_gervaz", "Content") end
    rows 8
    extra %{content_editor: true}
  end
end
elixir
def textarea(%{extra: %{code: language}} = assigns) when is_binary(language) do
  ...
end

def textarea(%{extra: %{content_editor: true}} = assigns) do
  ...
end

def textarea(assigns), do: super(assigns)

Defining textarea/1 takes the whole function over from the fallback. The last clause calls super(assigns) so that every other textarea reaches the fallback. Without it, a plain textarea on the same form raises FunctionClauseError. Gervaz does not check the keys of extra, so read the module that consumes them before you fill one.

Build an adapter from your own components

If your application already has a components module with matching names, wire it with components:. Gervaz connects each function only when the module is loaded and exports the matching one-argument function.

elixir
defmodule MyAppWeb.UIAdapter do
  use MishkaGervaz.Behaviours.UIAdapter,
    components: MyAppWeb.Components
end

nested_components: true looks for MyAppWeb.Components.Button.button/1. With nested_components: true, module_prefix: "Mishka" looks for Components.MishkaButton.button/1. component_prefix: "mc_" looks for mc_button/1. In a table's presentation, ui_adapter_opts component_module: MyAppWeb.Components makes Gervaz generate an adapter named after the resource, <Resource>.GervazUIAdapter, from the same options.

Resolve components at run time

MishkaGervaz.UIAdapters.Dynamic renders components looked up at run time and falls back per component when one is unavailable. Give it a renderer and a resolver:

elixir
presentation do
  ui_adapter MishkaGervaz.UIAdapters.Dynamic

  ui_adapter_opts site: "Global",
                  component_renderer: &MyApp.LiveViewHelpers.component/1,
                  module_resolver: &MyApp.Compilers.Helpers.module_name/3,
                  fallback: MishkaGervaz.UIAdapters.Tailwind
end

Next steps