What You Get

Introduction

Kaffy was created out of a need to have a powerfully simple, flexible, and customizable admin interface without the need to touch the current codebase. It was inspired by django’s lovely built-in admin app and rails’ powerful activeadmin gem.

Sections

Demo

Check out the simple demo here

Minimum Requirements

Installation

Add kaffy as a dependency

def deps do
  [
    {:kaffy, "~> 0.8.0"}
  ]
end

These are the minimum configurations required

# in your router.ex
use Kaffy.Routes, scope: "/admin", pipe_through: [:some_plug, :authenticate]
# :scope defaults to "/admin"
# :pipe_through defaults to kaffy's [:kaffy_browser]
# when providing pipelines, they will be added after :kaffy_browser
# so the actual pipe_through for the previous line is:
# [:kaffy_browser, :some_plug, :authenticate]

# in your endpoint.ex
plug Plug.Static,
  at: "/kaffy",
  from: :kaffy,
  gzip: false,
  only: ~w(assets)

# in your config/config.exs
config :kaffy,
  otp_app: :my_app,
  ecto_repo: MyApp.Repo,
  router: MyAppWeb.Router

Note that providing pipelines with the :pipe_through option will add those pipelines to kaffy’s :kaffy_browser pipeline which is defined as follows:

pipeline :kaffy_browser do
  plug :accepts, ["html", "json"]
  plug :fetch_session
  plug :fetch_flash
  plug :protect_from_forgery
  plug :put_secure_browser_headers
end

Customizations

Configurations

If you don’t specify a resources option in your configs, Kaffy will try to auto-detect your schemas and your admin modules. Admin modules should be in the same namespace as their respective schemas in order for kaffy to detect them. For example, if you have a schema MyApp.Products.Product, its admin module should be MyApp.Products.ProductAdmin.

Otherwise, if you’d like to explicitly specify your schemas and their admin modules, you can do like the following:

# config.exs
config :kaffy,
  admin_title: "My Awesome App",
  hide_dashboard: false,
  home_page: [kaffy: :dashboard],
  ecto_repo: MyApp.Repo,
  router: MyAppWeb.Router,
  resources: [
    blog: [
      name: "My Blog", # a custom name for this context/section.
      schemas: [
        post: [schema: MyApp.Blog.Post, admin: MyApp.SomeModule.Anywhere.PostAdmin],
        comment: [schema: MyApp.Blog.Comment],
        tag: [schema: MyApp.Blog.Tag]
      ]
    ],
    inventory: [
      name: "Inventory",
      schemas: [
        category: [schema: MyApp.Products.Category, admin: MyApp.Products.CategoryAdmin],
        product: [schema: MyApp.Products.Product, admin: MyApp.Products.ProductAdmin]
      ]
    ]
  ]

You can set the :hide_dashboard option to true to hide the dashboard link from the side menu. To change the home page, change the :home_page option to one of the following:

Note that, for auto-detection to work properly, schemas in different contexts should have different direct “prefix” namespaces. That is:

# auto-detection works properly with this:
MyApp.Posts.Post
MyApp.Posts.Category
MyApp.Products.Product
MyApp.Products.Category # this Category will not be confused with Posts.Category

# auto-detection will be confused with this:
# both Category schemas have the same "Schemas" prefix.
MyApp.Posts.Schemas.Post
MyApp.Posts.Schemas.Category
MyApp.Products.Schemas.Product
MyApp.Products.Schemas.Category

# To fix this, define resources manually:
resources: [
  posts: [
    schemas: [
      post: [schema: MyApp.Posts.Schemas.Post],
      category: [schema: MyApp.Posts.Schemas.Category]
    ]
  ],
  products: [
    schemas: [
      product: [schema: MyApp.Products.Schemas.Product],
      category: [schema: MyApp.Products.Schemas.Category]
    ]
  ]
]

Dashboard page

Kaffy supports dashboard customizations through widgets.

Dashboard page widgets

Currently, kaffy provides support for 4 types of widgets:

Widgets have shared options:

When defining a chart widget, the content must be a map with the following required keys:

To create widgets, define widgets/2 in your admin modules.

widgets/2 takes a schema and a conn and must return a list of widget maps:

defmodule MyApp.Products.ProductAdmin do
  def widgets(_schema, _conn) do
    [
      %{
        type: "tidbit",
        title: "Average Reviews",
        content: "4.7 / 5.0",
        icon: "thumbs-up",
        order: 1,
        width: 6,
      },
      %{
        type: "progress",
        title: "Pancakes",
        content: "Customer Satisfaction",
        percentage: 79,
        order: 3,
        width: 6,
      },
      %{
        type: "chart",
        title: "This week's sales",
        order: 8,
        width: 12,
        content: %{
          x: ["Mon", "Tue", "Wed", "Thu", "Today"],
          y: [150, 230, 75, 240, 290],
          y_title: "USD"
        }
      }
    ]
  end
end

Kaffy will collect all widgets from all admin modules and orders them based on the :order option if present and displays them on the dashboard page.

Side Menu

Custom Links

Kaffy provides support for adding custom links to the side navigation menu.

defmodule MyApp.Products.ProductAdmin do
  def custom_links(_schema) do
    [
      %{name: "Source Code", url: "https://example.com/repo/issues", order: 2, location: :top, icon: "paperclip"},
      %{name: "Products On Site", url: "https://example.com/products", location: :sub, target: "_blank"},
    ]
  end
end

custom_links/1 takes a schema and should return a list of maps with the following keys:

Custom Pages

Kaffy allows you to add custom pages like the following:

Custom Pages

To add custom pages, you need to define the custom_pages/2 function in your admin module:

defmodule MyApp.Products.ProductAdmin do
  def custom_pages(_schema, _conn) do
    [
      %{
        slug: "my-own-thing",
        name: "Secret Place",
        view: MyAppWeb.ProductView,
        template: "custom_product.html",
        assigns: [custom_message: "one two three"],
        order: 2
      }
    ]
  end
end

The custom_pages/2 function takes a schema and a conn and must return a list of maps corresponding to pages. The maps have the following keys:

Index page

The index/1 function takes a schema and must return a keyword list of fields and their options.

If the options are nil, Kaffy will use default values for that field.

If this function is not defined, Kaffy will return all fields with their respective values.

defmodule MyApp.Blog.PostAdmin do
  def index(_) do
    [
      title: nil,
      views: %{name: "Hits"},
      date: %{name: "Date Added", value: fn p -> p.inserted_at end},
      good: %{name: "Popular?", value: fn _ -> Enum.random(["Yes", "No"]) end}
    ]
  end
end

Result

Customized index page

Notice that the keyword list keys don’t necessarily have to be schema fields as long as you provide a :value option.

You can also provide some basic column-based filtration by providing the :filters option:

defmodule MyApp.Products.ProductAdmin do
  def index(_) do
    [
      title: nil,
      category_id: %{
        value: fn p -> get_category!(p.category_id).name end,
        filters: Enum.map(list_categories(), fn c -> {c.name, c.id} end)
      },
      price: %{value: fn p -> Decimal.to_string(p.price) end},
      quantity: nil,
      status: %{
        name: "Is it available?",
        value: fn p -> available?(p) end,
        filters: [{"Available", "available"}, {"Sold out", "soldout"}]
      },
      views: nil
    ]
  end
end

:filters must be a list of tuples where the first element is a human-frieldy string and the second element is the actual field value used to filter the records.

Result

Product filters

If you need to change the order of the records, define ordering/1:

defmodule MyApp.Blog.PostAdmin do
  def ordering(_schema) do
    # order posts based on views
    [desc: :views]
  end
end

Form Page

Kaffy treats the show and edit pages as one, the form page.

To customize the fields shown in this page, define a form_fields/1 function in your admin module.

defmodule MyApp.Blog.PostAdmin do
  def form_fields(_) do
    [
      title: nil,
      status: %{choices: [{"Publish", "publish"}, {"Pending", "pending"}]},
      body: %{type: :textarea, rows: 4},
      views: %{create: :hidden, update: :readonly},
      settings: %{label: "Post Settings"}
    ]
  end
end

The form_fields/1 function takes a schema and should return a keyword list of fields and their options.

The keys of the list must correspond to the schema fields.

Options can be:

Result

Customized show/edit page

Notice that:

Setting a field’s type to :richtext will render a rich text editor.

Embedded Schemas and JSON Fields

Kaffy has support for ecto’s embedded schemas and json fields. When you define a field as a :map, Kaffy will automatically display a textarea with a placeholder to hint that JSON content is expected. When you have an embedded schema, Kaffy will try to render each field inline with the form of the parent schema.

Search

Kaffy provides very basic search capabilities.

Currently, only :string and :text fields are supported for search.

If you need to customize the list of fields to search against, define the search_fields/1 function.

defmodule MyApp.Blog.PostAdmin do
  def search_fields(_schema) do
    [:title, :slug, :body]
  end
end

Kaffy allows to search for fields across associations. The following tells kaffy to search posts by title and body and category’s name and description:

# Post has a belongs_to :category association
defmodule MyApp.Blog.PostAdmin do
  def search_fields(_schema) do
    [
      :title,
      :body,
      category: [:name, :description]
    ]
  end
end

This function takes a schema and returns a list of schema fields that you want to search. All the fields must be of type :string or :text.

If this function is not defined, Kaffy will return all :string and :text fields by default.

Authorization

Kaffy supports basic authorization for individual schemas by defining authorized?/2.

defmodule MyApp.Blog.PostAdmin do
  def authorized?(_schema, conn) do
    MyApp.Blog.can_see_posts?(conn.assigns.user)
  end
end

authorized?/2 takes a schema and a Plug.Conn struct and should return a boolean value.

If it returns false, the request is redirected to the dashboard with an unauthorized message.

Note that the resource is also removed from the resources list if authorized?/2 returns false.

Changesets

Kaffy supports separate changesets for creating and updating schemas.

Just define create_changeset/2 and update_changeset/2.

Both of them are passed the schema and the attributes.

defmodule MyApp.Blog.PostAdmin do
  def create_changeset(schema, attrs) do
    # do whatever you want, must return a changeset
    MyApp.Blog.Post.my_customized_changeset(schema, attrs)
  end

  def update_changeset(entry, attrs) do
    # do whatever you want, must return a changeset
    MyApp.Blog.Post.update_changeset(entry, attrs)
  end
end

If either function is not defined, Kaffy will try calling Post.changeset/2.

And if that is not defined, Ecto.Changeset.change/2 will be called.

Singular vs Plural

Some names do not follow the “add an s” rule. Sometimes you just need to change some terms to your liking.

This is why singular_name/1 and plural_name/1 are there.

defmodule MyApp.Blog.PostAdmin do
  def singular_name(_) do
    "Article"
  end

  def plural_name(_) do
    "Terms"
  end
end

Custom Actions

Single Resource Actions

Kaffy supports performing custom actions on single resources by defining the resource_actions/1 function.

defmodule MyApp.Blog.ProductAdmin
  def resource_actions(_conn) do
    [
      publish: %{name: "Publish this product", action: fn _c, p -> restock(p) end},
      soldout: %{name: "Sold out!", action: fn _c, p -> soldout(p) end}
    ]
  end

  defp restock(product) do
    update_product(product, %{"status" => "available"})
  end

  defp soldout(product) do
    case product.id == 3 do
      true ->
        {:error, product, "This product should never be sold out!"}

      false ->
        update_product(product, %{"status" => "soldout"})
    end
  end

Result

Single actions

resource_actions/1 takes a conn and must return a keyword list. The keys must be atoms defining the unique action “keys”. The values are maps providing a human-friendly :name and an :action that is an anonymous function with arity 2 that takes a conn and the record.

Actions must return one of the following:

List Actions

Kaffy also supports actions on a group of resources. You can enable list actions by defining list_actions/1.

defmodule MyApp.Products.ProductAdmin do
  def list_actions(_conn) do
    [
      soldout: %{name: "Mark as soldout", action: fn _, products -> list_soldout(products) end},
      restock: %{name: "Bring back", action: fn _, products -> bring_back(products) end},
      not_good: %{name: "Error me out", action: fn _, _ -> {:error, "Expected error"} end}
    ]
  end
end

Result

List actions

list_actions/1 takes a conn and must return a keyword list. The keys must be atoms defining the unique action “keys”. The values are maps providing a human-friendly :name and an :action that is an anonymous function with arity 2 that takes a conn and a list of selected records.

List actions must return one of the following:

Callbacks

Sometimes you need to execute certain actions when creating, updating, or deleting records.

Kaffy has your back.

There are a few callbacks that are called every time you create, update, or delete a record.

These callbacks are:

before_* functions are passed the current conn and a changeset. after_* functions are passed the current conn and the record itself. With the exception of before_delete/2 and after_delete/2 which are both passed the current conn and the record itself.

To prevent the chain from continuing and roll back any changes:

When creating a new record, the following functions are called in this order:

When updating an existing record, the following functions are called in this order:

When deleting a record, the following functions are called in this order:

It’s important to know that all callbacks are run inside a transaction. So in case of failure, everything is rolled back even if the operation actually happened.

defmodule MyApp.Blog.PostAdmin do
  def before_create(conn, changeset) do
    case conn.assigns.user.username == "aesmail" do
      true -> {:error, changeset} # aesmail should never create a post
      false -> {:ok, changeset}
    end
  end

  def after_create(_conn, post) do
    {:error, post, "This will prevent posts from being created"}
  end

  def before_delete(conn, post) do
    case conn.assigns.user.role do
      "admin" -> {:ok, post}
      _ -> {:error, post, "Only admins can delete posts"}
    end
  end
end

Scheduled Tasks

Kaffy supports simple scheduled tasks. Tasks are functions that are run periodically. Behind the scenes, they are put inside GenServers and supervised with a DynamicSupervisor.

To create scheduled tasks, simply define a scheduled_tasks/1 function in your admin module:

defmodule MyApp.Products.ProductAdmin do
  def scheduled_tasks(_) do
    [
      %{
        name: "Cache Product Count",
        initial_value: 0,
        every: 15,
        action: fn _v ->
          count = Bakery.Products.cache_product_count()
          # "count" will be passed to this function in its next run.
          {:ok, count}
        end
      },
      %{
        name: "Delete Fake Products",
        every: 60,
        initial_value: nil,
        action: fn _ ->
          Bakery.Products.delete_fake_products()
          {:ok, nil}
        end
      }
    ]
  end
end

Once you create your scheduled tasks, a new “Tasks” menu item will show up (below the Dashboard item) listing all your tasks with some tiny bits of information about each task like the following image:

Simple scheduled tasks

The scheduled_tasks/1 function takes a schema and must return a list of tasks.

A task is a map with the following keys:

The initial_value is passed to the action function in its first run.

The action function must return one of the following values:

In case the action function crashes, the task will be brought back up again in its initial state that is defined in the scheduled_tasks/1 function and the “Started” time will change to indicate the new starting time. This will also reset the successful and failed run counts to 0.

Note that since scheduled tasks are run with GenServers, they are stored and kept in memory. Having too many scheduled tasks under low memory conditions can cause an out of memory exception.

Scheduled tasks should be used for simple, non-critical operations.

The Driving Points

A few points that encouraged the creation and development of Kaffy: