# Kaffy [![Module Version](https://img.shields.io/hexpm/v/kaffy.svg)](https://hex.pm/packages/kaffy) [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/kaffy/) [![Total Download](https://img.shields.io/hexpm/dt/kaffy.svg)](https://hex.pm/packages/kaffy) [![License](https://img.shields.io/hexpm/l/kaffy.svg)](https://github.com/aesmail/kaffy/blob/master/LICENSE.md) [![Last Updated](https://img.shields.io/github/last-commit/aesmail/kaffy.svg)](https://github.com/aesmail/kaffy/commits/master) ![What You Get](assets/kaffy_index.png) ## 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 - [Introduction](#introduction) - [Sections](#sections) - [Sponsors](#sponsors) - [Demo](#demo) - [Minimum Requirements](#minimum-requirements) - [Installation](#installation) - [Add `kaffy` as a dependency](#add-kaffy-as-a-dependency) - [These are the minimum configurations required](#these-are-the-minimum-configurations-required) - [Customizations](#customizations) - [Configurations](#configurations) - [Breaking change in v0.9](#breaking-change-in-v09) - [Dashboard page](#dashboard-page) - [Side Menu](#side-menu) - [Custom Links](#custom-links) - [Custom Pages](#custom-pages) - [Index pages](#index-pages) - [Form Pages](#form-pages) - [Association Forms](#association-forms) - [Custom Form Fields](#custom-form-fields) - [Customize the Queries](#customize-the-queries) - [Extensions](#extensions) - [Embedded Schemas and JSON Fields](#embedded-schemas-and-json-fields) - [Search](#search) - [Authorization](#authorization) - [Changesets](#changesets) - [Singular vs Plural](#singular-vs-plural) - [Custom Actions](#custom-actions) - [Single Resource Actions](#single-resource-actions) - [List Actions](#list-actions) - [Callbacks](#callbacks) - [Overwrite actions](#overwrite-actions) - [Scheduled Tasks](#scheduled-tasks) - [The Driving Points](#the-driving-points) ## Sponsors Sponsor the [development of Kaffy](https://github.com/sponsors/aesmail) through GitHub Sponsors. ## Demo [Check out the simple demo here](https://kaffy.fly.dev/admin/) ## Minimum Requirements Starting with v0.10.0, Kaffy will officially support the latest two phoenix versions. | Kaffy | Supported phoenix versions | |---------|----------------------------| | v0.10.0 | 1.6, 1.7 | | v0.9.X | 1.5, 1.6, 1.7 | | | | ## Support Policy The latest released `major.minor` version will be supported. For example, if the latest version is `0.9.0`, then `0.9.1` will be released with bug fixes. If a new version `0.10.0` is released, then `0.9.1` will no longer receive bug fixes or security patches. ## Installation #### Add `:kaffy` as a dependency ```elixir def deps do [ {:kaffy, "~> 0.10.0"} ] end ``` If you are using `kaffy` v0.9.x with `phoenix` 1.7, you need to add `phoenix_view` to your dependencies: ```elixir def deps do [ {:phoenix_view, "~> 2.0.2"}, {:kaffy, "~> 0.9.4"} ] end ``` #### These are the minimum configurations required ```elixir # 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 # configure the path to your application static assets in :at # the path must end with `/kaffy` plug Plug.Static, at: "/kaffy", # or "/path/to/your/static/kaffy" from: :kaffy, gzip: false, only: ~w(assets) # in your config/config.exs config :kaffy, # required keys otp_app: :my_app, # required ecto_repo: MyApp.Repo, # required router: MyAppWeb.Router, # required # optional keys admin_title: "My Awesome App", admin_logo: [ url: "https://example.com/img/logo.png", style: "width:200px;height:66px;" ], admin_logo_mini: "/images/logo-mini.png", hide_dashboard: true, home_page: [schema: [:accounts, :user]], enable_context_dashboards: true, # since v0.10.0 admin_footer: "Kaffy © 2023" # since v0.10.0 ``` Note that providing pipelines with the `:pipe_through` option will add those pipelines to kaffy's `:kaffy_browser` pipeline which is defined as follows: ```elixir pipeline :kaffy_browser do plug :accepts, ["html", "json"] plug :fetch_session plug :fetch_flash plug :protect_from_forgery plug :put_secure_browser_headers end ``` ### Phoenix version 1.7 Note that if you use Phoenix version 1.7 you also need to manually add the use of phoenix views in your project. Follow the instructions at https://hexdocs.pm/phoenix_view/Phoenix.View.html You will also need to change `helpers: false` to `true` in the `myapp_web.ex` file as shown in example below. ```elixir # lib/myapp_web.ex def router do quote do use Phoenix.Router, helpers: true # <- set to true ``` ## Customizations ### Configurations #### Breaking change in v0.9 If you're upgrading from an earlier version to v0.9, you need to replace your `:schemas` with `:resources`. 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: ```elixir # config.exs config :kaffy, admin_title: "My Awesome App", admin_logo: "/images/logo.png", admin_logo_mini: "/images/logo-mini.png", admin_footer: "Kaffy © 2023", hide_dashboard: false, enable_context_dashboards: true, home_page: [kaffy: :dashboard], ecto_repo: MyApp.Repo, router: MyAppWeb.Router, resources: &MyApp.Kaffy.Config.create_resources/1 # in your custom resources function defmodule MyApp.Kaffy.Config do def create_resources(_conn) do [ blog: [ name: "My Blog", # a custom name for this context/section. resources: [ # this line used to be "schemas" in pre v0.9 post: [schema: MyApp.Blog.Post, admin: MyApp.SomeModule.Anywhere.PostAdmin], comment: [schema: MyApp.Blog.Comment], tag: [schema: MyApp.Blog.Tag, in_menu: false] ] ], inventory: [ name: "Inventory", resources: [ category: [schema: MyApp.Products.Category, admin: MyApp.Products.CategoryAdmin], product: [schema: MyApp.Products.Product, admin: MyApp.Products.ProductAdmin] ] ] ] end end ``` Starting with Kaffy v0.9, the `:resources` option can take a literal list or a function. If a function is provided, it should take a conn and return a list of contexts and schemas like in the example above. Passing a conn to the function provides more flexibility and customization to your resources list. 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: - `[kaffy: :dashboard]` for the default dashboard page. - `[schema: ["blog", "post"]]` to make the home page the index page for the `Post` schema under the 'Blog' context. - `[page: "my-custom-page"]` to make the custom page with the `:slug` "my-custom-page" the home page. See the Custom Pages section below. Note that, for auto-detection to work properly, schemas in different contexts should have different direct "prefix" namespaces. That is: ```elixir # 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: [ resources: [ post: [schema: MyApp.Posts.Schemas.Post], category: [schema: MyApp.Posts.Schemas.Category] ] ], products: [ resources: [ product: [schema: MyApp.Products.Schemas.Product], category: [schema: MyApp.Products.Schemas.Category] ] ] ] ``` ### Dashboard page Kaffy supports dashboard customizations through `widgets`. ![Dashboard page widgets](assets/kaffy_dashboard.png) Currently, kaffy provides support for 4 types of widgets: - `text` widgets. Suitable for display relatively long textual information. Candidates: a short review, a specific message for the admin, etc. - `tidbit` widgets. Suitable for tiny bits of information (one word, or one number). Cadidates: total sales, a specific date, system status ("Healthy", "Down"), etc. - `progress` widgets. Suitable for measuring progress in terms of percentages. Candidates: task progress, survey results, memory usage, etc. - `chart` widgets. Suitable for displaying chart data with X and Y values. Candidates: any measurable number over a period of time (e.g. sales, visits, etc). Widgets have shared options: - `:type` (required) is the type of the widget. Valid options are `text`, `tidbit`, `progress`, and `chart`. - `:title` (required) is the title for the widget. What this widget is about. - `:content` (required) is the main content of the widget. This can be a string or a map depending on the type of widget. - `:order` (optional) is the displaying order of the widget. Widgets are display in order based on this value. The default value is 999. - `:width` (optional) is the width the widget should occupy on the page. Valid values are 1 to 12. The default for tidbits is 3 and the others 6. - `:percentage` (required for progress widgets) is the percentage value for the progress. This must be an integer. - `:full_icon` (optional for tidbit widgets) is the icon displayed next to the tidbit's `content`. You have to specify the full name given by FontAwesome like `fas fa-thumbs-up`. - `:icon` (optional for tidbit widgets) is the icon displayed next to the tidbit's `content`. Any FontAwesome-valid icon is valid here. For example: `thumbs-up`. But it's limited to the `fas` group. For full defintion see `:full_icon`. When defining a chart widget, the content must be a map with the following required keys: - `:x` must be a list of values for the x-axis. - `:y` must be a list of numbers (integers/floats) for the y-axis. - `:y_title` must be a string describing `:y` (e.g. USD, Transactions, Visits, etc) 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: ```elixir 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. ```elixir 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"}, %{name: "Support us", url: "https://example.com/products", location: :bottom, target: "_blank", icon: "usd"}, ] end end ``` `custom_links/1` takes a schema and should return a list of maps with the following keys: - `:name` to display as the text for the link. - `:url` to contain the actual URL. - `:method` the method to use with the link. - `:order` to hold the displayed order of this link. All `:sub` links are ordered under the schema menu item directly before the following schema. - `:location` can be either `:sub`, `:top` or `:bottom`. `:sub` means it's under the schema sub-item. `:top` means it's displayed at the top of the menu below the "Dashboard" link. `:bottom` means it's displayed at the bottom of the menu below the last context menu item. Links are ordered based on the `:order` value. The default value is `:sub`. - `:icon` is the icon displayed next to the link. Any FontAwesome-valid icon is valid here. For example: `paperclip`. - `:target` to contain the target to open the link: `_blank` or `_self`. `_blank` will open the link in a new window/tab, `_self` will open the link in the same window. The default value is `_self`. ### Custom Pages Kaffy allows you to add custom pages like the following: ![Custom Pages](assets/kaffy_custom_pages.png) To add custom pages, you need to define the `custom_pages/2` function in your admin module: ```elixir 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: - `:slug` to indicate the url of the page, e.g., `/admin/p/my-own-thing`. - `:name` for the name of the link on the side menu. - `:view` to set the view from your own app. - `:template` to set the custom template you want to render in Kaffy's layout. - `:assigns` (optional) to hold the assigns for the template. Default to an empty list. - `:order` is the order of the page among other pages in the side menu. ### Index pages 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. ```elixir defmodule MyApp.Blog.PostAdmin do def popular?(p) do if (p.popular), do: "✅", else: "❌" end def index(_) do [ title: nil, views: %{name: "Hits"}, date: %{name: "Date Added", value: fn p -> p.inserted_at end}, popular: %{name: "Popular?", value: fn p -> popular?(p) end}, ] end end ``` Result ![Customized index page](assets/kaffy_index.png) 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: ```elixir 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](assets/kaffy_filters.png) If you need to change the order of the records, define `ordering/1`: ```elixir defmodule MyApp.Blog.PostAdmin do def ordering(_schema) do # order posts based on views [desc: :views] end end ``` If you need to hide the "New " button, you can define the `default_actions/1` function in your admin module: ```elixir defmodule MyApp.Blog.PostAdmin do def default_actions(_schema) do # default actions are [:new, :edit, :delete] by default. [:delete] # cannot create or edit posts, can only delete. end end ``` ### Form Pages 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. ```elixir 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"}, slug: %{help_text: "Define your own slug for the post, if empty one will be created for you using the post title."} ] 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: - `:label` - must be a string. - `:type` - can be any ecto type in addition to `:file`, `:textarea`, and `:richtext`. - `:rows` - an integer to indicate the number of rows for textarea fields. - `:choices` - a keyword list of option and values to restrict the input values that this field can accept. - `:create` - can be `:editable` which means it can be edited when creating a new record, or `:readonly` which means this field is visible when creating a new record but cannot be edited, or `:hidden` which means this field shouldn't be visible when creating a new record. It is `:editable` by default. - `:update` - can be `:editable` which means it can be edited when updating an existing record, or `:readonly` which means this field is visible when updating a record but cannot be edited, or `:hidden` which means this field shouldn't be visible when updating record. It is `:editable` by default. - `:help_text` - extra "help text" to be displayed with the form field. - `:values_fn` - This allows passing in a function to populate the list of possible values for a `:array` field. The field will be rendered as a multi-select input. The function should be of arity 2 and the arguments are the entity and the conn. See example below Result ![Customized show/edit page](assets/kaffy_form.png) Notice that: - Even though the `status` field is of type `:string`, it is rendered as a `