{"id":13491493,"url":"https://github.com/aesmail/kaffy","last_synced_at":"2025-05-13T17:08:35.937Z","repository":{"id":38019656,"uuid":"261934930","full_name":"aesmail/kaffy","owner":"aesmail","description":"Powerfully simple admin package for phoenix applications","archived":false,"fork":false,"pushed_at":"2025-02-23T22:06:02.000Z","size":14833,"stargazers_count":1394,"open_issues_count":72,"forks_count":171,"subscribers_count":14,"default_branch":"master","last_synced_at":"2025-05-08T10:01:43.016Z","etag":null,"topics":["admin","dashboard","ecto","elixir","phoenix"],"latest_commit_sha":null,"homepage":"https://kaffy.fly.dev/admin/","language":"Elixir","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/aesmail.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":".github/FUNDING.yml","license":"LICENSE.md","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null},"funding":{"github":"aesmail","patreon":null,"open_collective":null,"ko_fi":null,"tidelift":null,"community_bridge":null,"liberapay":null,"issuehunt":null,"otechie":null,"lfx_crowdfunding":null,"custom":null}},"created_at":"2020-05-07T02:56:34.000Z","updated_at":"2025-05-03T20:17:22.000Z","dependencies_parsed_at":"2024-03-06T17:19:49.489Z","dependency_job_id":"4d5cf577-5e19-4881-ae43-c91a71637ee1","html_url":"https://github.com/aesmail/kaffy","commit_stats":{"total_commits":422,"total_committers":44,"mean_commits":9.590909090909092,"dds":"0.33175355450236965","last_synced_commit":"87e2d5ec95e1628fe23dc02f4acf3a1d572d77e4"},"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/aesmail%2Fkaffy","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/aesmail%2Fkaffy/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/aesmail%2Fkaffy/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/aesmail%2Fkaffy/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/aesmail","download_url":"https://codeload.github.com/aesmail/kaffy/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":253990467,"owners_count":21995774,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["admin","dashboard","ecto","elixir","phoenix"],"created_at":"2024-07-31T19:00:57.506Z","updated_at":"2025-05-13T17:08:35.895Z","avatar_url":"https://github.com/aesmail.png","language":"Elixir","readme":"# Kaffy\n\n[![Module Version](https://img.shields.io/hexpm/v/kaffy.svg)](https://hex.pm/packages/kaffy)\n[![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/kaffy/)\n[![Total Download](https://img.shields.io/hexpm/dt/kaffy.svg)](https://hex.pm/packages/kaffy)\n[![License](https://img.shields.io/hexpm/l/kaffy.svg)](https://github.com/aesmail/kaffy/blob/master/LICENSE.md)\n[![Last Updated](https://img.shields.io/github/last-commit/aesmail/kaffy.svg)](https://github.com/aesmail/kaffy/commits/master)\n\n![What You Get](assets/kaffy_index.png)\n\n## Introduction\n\nKaffy was created out of a need to have a powerfully simple, flexible, and customizable admin interface\nwithout the need to touch the current codebase. It was inspired by django's lovely built-in `admin` app and rails' powerful `activeadmin` gem.\n\n## Sections\n\n- [Introduction](#introduction)\n- [Sections](#sections)\n- [Sponsors](#sponsors)\n- [Demo](#demo)\n- [Minimum Requirements](#minimum-requirements)\n- [Installation](#installation)\n    - [Add `kaffy` as a dependency](#add-kaffy-as-a-dependency)\n    - [These are the minimum configurations required](#these-are-the-minimum-configurations-required)\n- [Customizations](#customizations)\n  - [Configurations](#configurations)\n    - [Breaking change in v0.9](#breaking-change-in-v09)\n  - [Dashboard page](#dashboard-page)\n  - [Side Menu](#side-menu)\n    - [Custom Links](#custom-links)\n  - [Custom Pages](#custom-pages)\n  - [Index pages](#index-pages)\n  - [Form Pages](#form-pages)\n    - [Association Forms](#association-forms)\n  - [Custom Form Fields](#custom-form-fields)\n  - [Customize the Queries](#customize-the-queries)\n  - [Extensions](#extensions)\n  - [Embedded Schemas and JSON Fields](#embedded-schemas-and-json-fields)\n  - [Search](#search)\n  - [Authorization](#authorization)\n  - [Changesets](#changesets)\n  - [Singular vs Plural](#singular-vs-plural)\n  - [Custom Actions](#custom-actions)\n    - [Single Resource Actions](#single-resource-actions)\n    - [List Actions](#list-actions)\n  - [Callbacks](#callbacks)\n  - [Overwrite actions](#overwrite-actions)\n  - [Scheduled Tasks](#scheduled-tasks)\n- [The Driving Points](#the-driving-points)\n\n## Sponsors\n\nSponsor the [development of Kaffy](https://github.com/sponsors/aesmail) through GitHub Sponsors.\n\n## Demo\n\n[Check out the simple demo here](https://kaffy.fly.dev/admin/)\n\n## Minimum Requirements\n\nStarting with v0.10.0, Kaffy will officially support the latest two phoenix versions.\n\n| Kaffy   | Supported phoenix versions |\n|---------|----------------------------|\n| v0.10.0 | 1.6, 1.7.0                 |\n| v0.9.X  | 1.5, 1.6, 1.7.0            |\n|         |                            |\n\n\n## Support Policy\n\nThe 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.\n\n## Installation\n\n#### Add `:kaffy` as a dependency\n```elixir\ndef deps do\n  [\n    {:kaffy, \"~\u003e 0.10.0\"}\n  ]\nend\n```\n\nIf you are using `kaffy` v0.9.x with `phoenix` 1.7, you need to add `phoenix_view` to your dependencies:\n```elixir\ndef deps do\n  [\n    {:phoenix_view, \"~\u003e 2.0.2\"},\n    {:kaffy, \"~\u003e 0.9.4\"}\n  ]\nend\n```\n\n#### These are the minimum configurations required\n\n```elixir\n# in your router.ex\nuse Kaffy.Routes, scope: \"/admin\", pipe_through: [:some_plug, :authenticate]\n# :scope defaults to \"/admin\"\n# :pipe_through defaults to kaffy's [:kaffy_browser]\n# when providing pipelines, they will be added after :kaffy_browser\n# so the actual pipe_through for the previous line is:\n# [:kaffy_browser, :some_plug, :authenticate]\n\n# in your endpoint.ex\n# configure the path to your application static assets in :at\n# the path must end with `/kaffy`\nplug Plug.Static,\n  at: \"/kaffy\", # or \"/path/to/your/static/kaffy\"\n  from: :kaffy,\n  gzip: false,\n  only: ~w(assets)\n\n# in your config/config.exs\nconfig :kaffy,\n  # required keys\n  otp_app: :my_app, # required\n  ecto_repo: MyApp.Repo, # required\n  router: MyAppWeb.Router, # required\n  # optional keys\n  admin_title: \"My Awesome App\",\n  admin_logo: [\n    url: \"https://example.com/img/logo.png\",\n    style: \"width:200px;height:66px;\"\n  ],\n  admin_logo_mini: \"/images/logo-mini.png\",\n  hide_dashboard: true,\n  home_page: [schema: [:accounts, :user]],\n  enable_context_dashboards: true, # since v0.10.0\n  admin_footer: \"Kaffy \u0026copy; 2023\" # since v0.10.0\n```\n\nNote that providing pipelines with the `:pipe_through` option will add those pipelines to kaffy's `:kaffy_browser` pipeline which is defined as follows:\n\n```elixir\npipeline :kaffy_browser do\n  plug :accepts, [\"html\", \"json\"]\n  plug :fetch_session\n  plug :fetch_flash\n  plug :protect_from_forgery\n  plug :put_secure_browser_headers\nend\n```\n### Phoenix version 1.7\nNote that if you use Phoenix version 1.7 you also need to manually add the use of phoenix views in your project.\nFollow the instructions at https://hexdocs.pm/phoenix_view/Phoenix.View.html\n\nYou will also need to change `helpers: false` to `true` in the `myapp_web.ex` file as shown in example below.\n```elixir\n  # lib/myapp_web.ex\n  def router do\n    quote do\n      use Phoenix.Router, helpers: true # \u003c- set to true\n```\n\n## Customizations\n\n### Configurations\n\n#### Breaking change in v0.9\n\nIf you're upgrading from an earlier version to v0.9, you need to replace your `:schemas` with `:resources`.\n\nIf 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`.\n\nOtherwise, if you'd like to explicitly specify your schemas and their admin modules, you can do like the following:\n\n```elixir\n# config.exs\nconfig :kaffy,\n  admin_title: \"My Awesome App\",\n  admin_logo: \"/images/logo.png\",\n  admin_logo_mini: \"/images/logo-mini.png\",\n  admin_footer: \"Kaffy \u0026copy; 2023\",\n  hide_dashboard: false,\n  enable_context_dashboards: true,\n  home_page: [kaffy: :dashboard],\n  ecto_repo: MyApp.Repo,\n  router: MyAppWeb.Router,\n  resources: \u0026MyApp.Kaffy.Config.create_resources/1\n\n# in your custom resources function\ndefmodule MyApp.Kaffy.Config do\n  def create_resources(_conn) do\n    [\n      blog: [\n        name: \"My Blog\", # a custom name for this context/section.\n        resources: [ # this line used to be \"schemas\" in pre v0.9\n          post: [schema: MyApp.Blog.Post, admin: MyApp.SomeModule.Anywhere.PostAdmin],\n          comment: [schema: MyApp.Blog.Comment],\n          tag: [schema: MyApp.Blog.Tag, in_menu: false]\n        ]\n      ],\n      inventory: [\n        name: \"Inventory\",\n        resources: [\n          category: [schema: MyApp.Products.Category, admin: MyApp.Products.CategoryAdmin],\n          product: [schema: MyApp.Products.Product, admin: MyApp.Products.ProductAdmin]\n        ]\n      ]\n    ]\n  end\nend\n```\n\nStarting with Kaffy v0.9, the `:resources` option can take a literal list or a function.\nIf a function is provided, it should take a conn and return a list of contexts and schemas like in the example above.\nPassing a conn to the function provides more flexibility and customization to your resources list.\n\nYou can set the `:hide_dashboard` option to true to hide the dashboard link from the side menu.\nTo change the home page, change the `:home_page` option to one of the following:\n\n- `[kaffy: :dashboard]` for the default dashboard page.\n- `[schema: [\"blog\", \"post\"]]` to make the home page the index page for the `Post` schema under the 'Blog' context.\n- `[page: \"my-custom-page\"]` to make the custom page with the `:slug` \"my-custom-page\" the home page. See the Custom Pages section below.\n\nNote that, for auto-detection to work properly, schemas in different contexts should have different direct \"prefix\" namespaces. That is:\n\n```elixir\n# auto-detection works properly with this:\nMyApp.Posts.Post\nMyApp.Posts.Category\nMyApp.Products.Product\nMyApp.Products.Category # this Category will not be confused with Posts.Category\n\n# auto-detection will be confused with this:\n# both Category schemas have the same \"Schemas\" prefix.\nMyApp.Posts.Schemas.Post\nMyApp.Posts.Schemas.Category\nMyApp.Products.Schemas.Product\nMyApp.Products.Schemas.Category\n\n# To fix this, define resources manually:\nresources: [\n  posts: [\n    resources: [\n      post: [schema: MyApp.Posts.Schemas.Post],\n      category: [schema: MyApp.Posts.Schemas.Category]\n    ]\n  ],\n  products: [\n    resources: [\n      product: [schema: MyApp.Products.Schemas.Product],\n      category: [schema: MyApp.Products.Schemas.Category]\n    ]\n  ]\n]\n```\n\n### Dashboard page\n\nKaffy supports dashboard customizations through `widgets`.\n\n![Dashboard page widgets](assets/kaffy_dashboard.png)\n\nCurrently, kaffy provides support for 4 types of widgets:\n\n- `text` widgets. Suitable for display relatively long textual information. Candidates: a short review, a specific message for the admin, etc.\n- `tidbit` widgets. Suitable for tiny bits of information (one word, or one number). Cadidates: total sales, a specific date, system status (\"Healthy\", \"Down\"), etc.\n- `progress` widgets. Suitable for measuring progress in terms of percentages. Candidates: task progress, survey results, memory usage, etc.\n- `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).\n\nWidgets have shared options:\n\n- `:type` (required) is the type of the widget. Valid options are `text`, `tidbit`, `progress`, and `chart`.\n- `:title` (required) is the title for the widget. What this widget is about.\n- `:content` (required) is the main content of the widget. This can be a string or a map depending on the type of widget.\n- `:order` (optional) is the displaying order of the widget. Widgets are display in order based on this value. The default value is 999.\n- `: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.\n- `:percentage` (required for progress widgets) is the percentage value for the progress. This must be an integer.\n- `: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`.\n- `: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`.\n\nWhen defining a chart widget, the content must be a map with the following required keys:\n\n- `:x` must be a list of values for the x-axis.\n- `:y` must be a list of numbers (integers/floats) for the y-axis.\n- `:y_title` must be a string describing `:y` (e.g. USD, Transactions, Visits, etc)\n\n\nTo create widgets, define `widgets/2` in your admin modules.\n\n`widgets/2` takes a schema and a `conn` and must return a list of widget maps:\n\n```elixir\ndefmodule MyApp.Products.ProductAdmin do\n  def widgets(_schema, _conn) do\n    [\n      %{\n        type: \"tidbit\",\n        title: \"Average Reviews\",\n        content: \"4.7 / 5.0\",\n        icon: \"thumbs-up\",\n        order: 1,\n        width: 6,\n      },\n      %{\n        type: \"progress\",\n        title: \"Pancakes\",\n        content: \"Customer Satisfaction\",\n        percentage: 79,\n        order: 3,\n        width: 6,\n      },\n      %{\n        type: \"chart\",\n        title: \"This week's sales\",\n        order: 8,\n        width: 12,\n        content: %{\n          x: [\"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Today\"],\n          y: [150, 230, 75, 240, 290],\n          y_title: \"USD\"\n        }\n      }\n    ]\n  end\nend\n```\n\nKaffy 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.\n\n### Side Menu\n\n#### Custom Links\n\nKaffy provides support for adding custom links to the side navigation menu.\n\n```elixir\ndefmodule MyApp.Products.ProductAdmin do\n  def custom_links(_schema) do\n    [\n      %{name: \"Source Code\", url: \"https://example.com/repo/issues\", order: 2, location: :top, icon: \"paperclip\"},\n      %{name: \"Products On Site\", url: \"https://example.com/products\", location: :sub, target: \"_blank\"},\n      %{name: \"Support us\", url: \"https://example.com/products\", location: :bottom, target: \"_blank\",  icon: \"usd\"},\n    ]\n  end\nend\n```\n\n`custom_links/1` takes a schema and should return a list of maps with the following keys:\n\n- `:name` to display as the text for the link.\n- `:url` to contain the actual URL.\n- `:method` the method to use with the link.\n- `:order` to hold the displayed order of this link. All `:sub` links are ordered under the schema menu item directly before the following schema.\n- `: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`.\n- `:icon` is the icon displayed next to the link. Any FontAwesome-valid icon is valid here. For example: `paperclip`.\n- `: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`.\n\n\n### Custom Pages\n\nKaffy allows you to add custom pages like the following:\n\n![Custom Pages](assets/kaffy_custom_pages.png)\n\nTo add custom pages, you need to define the `custom_pages/2` function in your admin module:\n\n```elixir\ndefmodule MyApp.Products.ProductAdmin do\n  def custom_pages(_schema, _conn) do\n    [\n      %{\n        slug: \"my-own-thing\",\n        name: \"Secret Place\",\n        view: MyAppWeb.ProductView,\n        template: \"custom_product.html\",\n        assigns: [custom_message: \"one two three\"],\n        order: 2\n      }\n    ]\n  end\nend\n```\n\nThe `custom_pages/2` function takes a schema and a conn and must return a list of maps corresponding to pages.\nThe maps have the following keys:\n\n- `:slug` to indicate the url of the page, e.g., `/admin/p/my-own-thing`.\n- `:name` for the name of the link on the side menu.\n- `:view` to set the view from your own app.\n- `:template` to set the custom template you want to render in Kaffy's layout.\n- `:assigns` (optional) to hold the assigns for the template. Default to an empty list.\n- `:order` is the order of the page among other pages in the side menu.\n\n### Index pages\n\nThe `index/1` function takes a schema and must return a keyword list of fields and their options.\n\nIf the options are `nil`, Kaffy will use default values for that field.\n\nIf this function is not defined, Kaffy will return all fields with their respective values.\n\n```elixir\ndefmodule MyApp.Blog.PostAdmin do\n\n  def popular?(p) do\n    if (p.popular), do: \"✅\", else: \"❌\"\n  end\n\n  def index(_) do\n    [\n      title: nil,\n      views: %{name: \"Hits\"},\n      date: %{name: \"Date Added\", value: fn p -\u003e p.inserted_at end},\n      popular: %{name: \"Popular?\", value: fn p -\u003e popular?(p) end},\n    ]\n  end\nend\n```\n\nResult\n\n![Customized index page](assets/kaffy_index.png)\n\nNotice that the keyword list keys don't necessarily have to be schema fields as long as you provide a `:value` option.\n\nYou can also provide some basic column-based filtration by providing the `:filters` option:\n\n```elixir\ndefmodule MyApp.Products.ProductAdmin do\n  def index(_) do\n    [\n      title: nil,\n      category_id: %{\n        value: fn p -\u003e get_category!(p.category_id).name end,\n        filters: Enum.map(list_categories(), fn c -\u003e {c.name, c.id} end)\n      },\n      price: %{value: fn p -\u003e Decimal.to_string(p.price) end},\n      quantity: nil,\n      status: %{\n        name: \"Is it available?\",\n        value: fn p -\u003e available?(p) end,\n        filters: [{\"Available\", \"available\"}, {\"Sold out\", \"soldout\"}]\n      },\n      views: nil\n    ]\n  end\nend\n```\n\n`: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.\n\nResult\n\n![Product filters](assets/kaffy_filters.png)\n\nIf you need to change the order of the records, define `ordering/1`:\n\n```elixir\ndefmodule MyApp.Blog.PostAdmin do\n  def ordering(_schema) do\n    # order posts based on views\n    [desc: :views]\n  end\nend\n```\n\nIf you need to hide the \"New \u003cSchema\u003e\" button, you can define the `default_actions/1` function in your admin module:\n\n```elixir\ndefmodule MyApp.Blog.PostAdmin do\n  def default_actions(_schema) do\n    # default actions are [:new, :edit, :delete] by default.\n    [:delete] # cannot create or edit posts, can only delete.\n  end\nend\n```\n\n### Form Pages\n\nKaffy treats the show and edit pages as one, the form page.\n\nTo customize the fields shown in this page, define a `form_fields/1` function in your admin module.\n\n```elixir\ndefmodule MyApp.Blog.PostAdmin do\n  def form_fields(_) do\n    [\n      title: nil,\n      status: %{choices: [{\"Publish\", \"publish\"}, {\"Pending\", \"pending\"}]},\n      body: %{type: :textarea, rows: 4},\n      views: %{create: :hidden, update: :readonly},\n      settings: %{label: \"Post Settings\"},\n      slug: %{help_text: \"Define your own slug for the post, if empty one will be created for you using the post title.\"}\n    ]\n  end\nend\n```\n\nThe `form_fields/1` function takes a schema and should return a keyword list of fields and their options.\n\nThe keys of the list must correspond to the schema fields.\n\nOptions can be:\n\n- `:label` - must be a string.\n- `:type` - can be any ecto type in addition to `:file`, `:textarea`, and `:richtext`.\n- `:rows` - an integer to indicate the number of rows for textarea fields.\n- `:choices` - a keyword list of option and values to restrict the input values that this field can accept.\n- `: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.\n- `: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.\n- `:help_text` - extra \"help text\" to be displayed with the form field.\n- `: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\n\nResult\n\n![Customized show/edit page](assets/kaffy_form.png)\n\nNotice that:\n\n- Even though the `status` field is of type `:string`, it is rendered as a `\u003cselect\u003e` element with choices.\n- The `views` field is rendered as \"readonly\" because it was set as `:readonly` for the update form.\n- `settings` is an embedded schema. That's why it is rendered as such.\n\n\nSetting a field's type to `:richtext` will render a rich text editor.\n\nThe `:values_fn` is passed the entity you are editing and the conn (in that order) and must return a list of tuples that represent the {name, value} to use in the multi select. An example of this is as follows:\n\n```elixir\ndef form_fields(_schema) do\n  [\n    ....\n    some_array_field: %{\n      values_fn: fn entity, conn -\u003e\n        some_values = MyApp.Thing.fetch_values(entity.id, conn)\n        Enum.map(some_values, \u0026{\u00261.name, \u00261.id})\n      end\n    }\n  ]\nend\n```\n\nIf you don't want users to be able to edit or delete records, you can define the `default_actions/1` function in your admin module:\n\n```elixir\ndefmodule MyApp.Blog.PostAdmin do\n  def default_actions(_schema) do\n    # default actions are [:new, :edit, :delete] by default.\n    [:new] # only create records, cannot edit or delete.\n  end\nend\n```\n\n#### Association Forms\n\nA `belongs_to` association should be referenced by the field name, *not* the association name. For example, a schema with the following association:\n\n\n```elixir\nschema \"my_model\" do\n  ...\n  belongs_to :owner, App.Owners.Owner\n  ...\nend\n```\n\nWould define `form_fields/1` like so:\n\n```elixir\ndef form_fields(_) do\n  [\n    ...\n    owner_id: nil,\n    ...\n  ]\nend\n```\n\n**NOTE:** `many_to_many` associations are currently not supported.\n\n### Custom Form Fields\n\nYou can create your own form fields very easily with Kaffy.\nJust follow the instructions on how to create a custom type for ecto and add 2 additional functions to the module:\n`render_form/5` and `render_index/3`.\nCheck the below example or a better example on the comments of [this issue](https://github.com/aesmail/kaffy/issues/54).\n\n```elixir\ndefmodule MyApp.Kaffy.URLField do\n  use Ecto.Type\n  def type, do: :string\n\n  # casting input from the form and making it \"storable\" inside the database column (:string)\n  def cast(url) when is_map(url) do\n    name = Map.get(url, \"one\")\n    link = Map.get(url, \"two\")\n    {:ok, ~s(\u003ca href=\"#{link}\"\u003e#{name}\u003c/a\u003e)}\n  end\n\n  # if the input is not a string, return an error\n  def cast(_), do: :error\n\n  # loading the raw value from the database and turning it into a expected data type for the form\n  def load(data) when is_binary(data) do\n    [[_, link]] = Regex.scan(~r/href=\"(.*)\"/, data)\n    [[_, name]] = Regex.scan(~r/\u003e(.*)\u003c/, data)\n\n    {:ok, %{\"one\" =\u003e name, \"two\" =\u003e link}}\n  end\n\n  # this function should return the HTML related to rendering the customized form field.\n  def render_form(_conn, changeset, form, field, _options) do\n    [\n      {:safe, ~s(\u003cdiv class=\"form-group\"\u003e)},\n      Phoenix.HTML.Form.label(form, field, \"Web URL\"),\n      Phoenix.HTML.Form.text_input(form, field,\n        placeholder: \"This is a custom field\",\n        class: \"form-control\",\n        name: \"#{form.name}[#{field}][one]\",\n        id: \"#{form.name}_#{field}_one\",\n        value: get_field_value(changeset, field, \"one\")\n      ),\n      Phoenix.HTML.Form.text_input(form, field,\n        placeholder: \"This is a custom field\",\n        class: \"form-control\",\n        name: \"#{form.name}[#{field}][two]\",\n        id: \"#{form.name}_#{field}_two\",\n        value: get_field_value(changeset, field, \"two\")\n      ),\n      {:safe, ~s(\u003c/div\u003e)}\n    ]\n  end\n\n  # this is how the field should be rendered on the index page\n  def render_index(resource, field, _options) do\n    case Map.get(resource, field) do\n      nil -\u003e\n        \"\"\n\n      details -\u003e\n        name = details[\"one\"]\n        link = details[\"two\"]\n        {:safe, ~s(\u003ca href=\"#{link}\"\u003e#{name}\u003c/a\u003e)}\n    end\n  end\n\n  defp get_field_value(changeset, field, subfield) do\n    field_value = Map.get(changeset.data, field)\n    Map.get(field_value || %{}, subfield, \"\")\n  end\nend\n```\n\n\n### Customize the Queries\n\nBy default Kaffy does a simple Ecto query to retrieve records.  You can customize the queries used by Kaffy by using `custom_index_query` and `custom_show_query`.  This allows you to preload associations to display associated data on your pages, for example.  Attempting to access an association without preloading it first will result in a `Ecto.Association.NotLoaded` exception.\n\n```elixir\ndefmodule MyApp.Blog.PostAdmin do\n  def custom_index_query(_conn, _schema, query) do\n    from(r in query, preload: [:tags])\n  end\n\n  def custom_show_query(_conn, _schema, query) do\n    case user_is_admin?(conn) do\n      true -\u003e from(r in query, preload: [:history])\n      false -\u003e query\n    end\n  end\nend\n```\n\nThe `custom_index_query/3` function takes a conn, the schema, and the query to customize, and it must return a query.\nIt is called when fetching the resources for the index page.\n\nThe `custom_show_query/3` is identifical to `custom_index_query/3`, but works when fetching a single resource in the show/edit page.\n\nIt's also possible to pass `opts` to the Repo operation, in this case, you just have to return a tuple instead, like below:\n\n```elixir\ndefmodule MyApp.Accounts.TenantAdmin do\n  def custom_index_query(_conn, _schema, query) do\n    {query, skip_tenant_id: true}\n  end\n\n  def custom_show_query(_conn, _schema, query) do\n    {query, skip_tenant_id: true}\n  end\nend\n```\n\n### Extensions\n\nExtensions allow you to define custom CSS, JavaScript, and HTML.\nFor example, you need to use a specific JavaScript library or customize the look and feel of Kaffy.\nThis is where extensions come in handy.\n\nExtensions are elixir modules which special functions.\n\n```elixir\ndefmodule MyApp.Kaffy.Extension do\n  def stylesheets(_conn) do\n    [\n      {:safe, ~s(\u003clink rel=\"stylesheet\" href=\"/kaffy/somestyle.css\" /\u003e)}\n    ]\n  end\n\n  def javascripts(_conn) do\n    [\n      {:safe, ~s(\u003cscript src=\"https://example.com/javascript.js\"\u003e\u003c/script\u003e)}\n    ]\n  end\nend\n```\n\nThere are currently 2 special functions supported in extensions: `stylesheets/1` and `javascripts/1`.\nBoth functions take a conn and must return a list of safe strings.\n`stylesheets/1` will add whatever you include at the end of the `\u003chead\u003e` tag.\n`javascripts/1` will add whatever you include there just before the closing `\u003c/body\u003e` tag.\n\nOnce you have your extension module, you need to add it to the `extensions` list in config:\n\n```elixir\nconfig :kaffy,\n  # other settings\n  extensions: [\n    MyApp.Kaffy.Extension\n  ]\n```\n\nYou can check [this issue](https://github.com/aesmail/kaffy/issues/54) to see an example which uses extensions with custom fields.\n\n### Embedded Schemas and JSON Fields\n\nKaffy has support for Ecto's [embedded schemas](https://hexdocs.pm/ecto/Ecto.Schema.html#embedded_schema/1) 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.\n\n### Search\n\nKaffy provides very basic search capabilities.\n\nSupported field types are: `:string`, `:textarea`, `:richtext`, `:id`, `:integer`, and `:decimal`.\n\nIf you need to customize the list of fields to search against, define the `search_fields/1` function.\n\n```elixir\ndefmodule MyApp.Blog.PostAdmin do\n  def search_fields(_schema) do\n    [:id, :title, :body, :views]\n  end\nend\n```\n\nKaffy allows to search for fields across associations. The following tells Kaffy to search posts by title and body and category's name and description:\n\n```elixir\n# Post has a belongs_to :category association\ndefmodule MyApp.Blog.PostAdmin do\n  def search_fields(_schema) do\n    [\n      :title,\n      :body,\n      :view,\n      category: [:name, :description]\n    ]\n  end\nend\n```\n\nThis function takes a schema and returns a list of schema fields that you want to search.\n\nIf this function is not defined, Kaffy will return all fields with supported types by default.\n\n### Authorization\n\nKaffy supports basic authorization for individual schemas by defining `authorized?/2`.\n\n```elixir\ndefmodule MyApp.Blog.PostAdmin do\n  def authorized?(_schema, conn) do\n    MyApp.Blog.can_see_posts?(conn.assigns.user)\n  end\nend\n```\n\n`authorized?/2` takes a schema and a `Plug.Conn` struct and should return a boolean value.\n\nIf it returns `false`, the request is redirected to the dashboard with an unauthorized message.\n\nNote that the resource is also removed from the resources list if `authorized?/2` returns false.\n\n### Changesets\n\nKaffy supports separate changesets for creating and updating schemas.\n\nJust define `create_changeset/2` and `update_changeset/2`.\n\nBoth of them are passed the schema and the attributes.\n\n```elixir\ndefmodule MyApp.Blog.PostAdmin do\n  def create_changeset(schema, attrs) do\n    # do whatever you want, must return a changeset\n    MyApp.Blog.Post.my_customized_changeset(schema, attrs)\n  end\n\n  def update_changeset(entry, attrs) do\n    # do whatever you want, must return a changeset\n    MyApp.Blog.Post.update_changeset(entry, attrs)\n  end\nend\n```\n\nIf either function is not defined, Kaffy will try calling `Post.changeset/2`.\n\nAnd if that is not defined, `Ecto.Changeset.change/2` will be called.\n\n### Singular vs Plural\n\nKaffy makes some effor to guess a correct plural form of the resource, but in some cases it will fail. Should this happen, you may want to set a correct name yourself.\n\nThis is why `singular_name/1` and `plural_name/1` are there.\n\n```elixir\ndefmodule MyApp.Blog.PostAdmin do\n  def singular_name(_) do\n    \"Article\"\n  end\n\n  def plural_name(_) do\n    \"Terms\"\n  end\nend\n```\n\n### Custom Actions\n\n#### Single Resource Actions\n\nKaffy supports performing custom actions on single resources by defining the `resource_actions/1` function.\n\n```elixir\ndefmodule MyApp.Blog.ProductAdmin\n  def resource_actions(_conn) do\n    [\n      publish: %{name: \"Publish this product\", action: fn _c, p -\u003e restock(p) end},\n      soldout: %{name: \"Sold out!\", action: fn _c, p -\u003e soldout(p) end}\n    ]\n  end\n\n  defp restock(product) do\n    update_product(product, %{\"status\" =\u003e \"available\"})\n  end\n\n  defp soldout(product) do\n    case product.id == 3 do\n      true -\u003e\n        {:error, product, \"This product should never be sold out!\"}\n\n      false -\u003e\n        update_product(product, %{\"status\" =\u003e \"soldout\"})\n    end\n  end\n```\n\nResult\n\n![Single actions](assets/kaffy_resource_actions.png)\n\n`resource_actions/1` takes a `conn` and must return a keyword list.\nThe keys must be atoms defining the unique action \"keys\".\nThe 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.\n\nActions must return one of the following:\n\n- `{:ok, record}` indicating the action was performed successfully.\n- `{:error, changeset}` indicating there was a validation error.\n- `{:error, record, custom_error}` to communicate a custom error message to the user where `custom_error` is a string.\n\n#### List Actions\n\nKaffy also supports actions on a group of resources. You can enable list actions by defining `list_actions/1`.\n\n```elixir\ndefmodule MyApp.Products.ProductAdmin do\n  def list_actions(_conn) do\n    [\n      change_price: %{\n        name: \"Change the price\",\n        inputs: [\n          %{name: \"new_price\", title: \"New Price\", default: \"3\"}\n        ],\n        action: fn _conn, products, params -\u003e change_price(products, params) end\n      },\n      soldout: %{name: \"Mark as soldout\", action: fn _, products -\u003e list_soldout(products) end},\n      restock: %{name: \"Bring back\", action: fn _, products -\u003e bring_back(products) end},\n      not_good: %{name: \"Error me out\", action: fn _, _ -\u003e {:error, \"Expected error\"} end}\n    ]\n  end\n\n  defp change_price(products, params) do\n      new_price = Map.get(params, \"new_price\") |\u003e Decimal.new()\n\n      Enum.map(products, fn p -\u003e\n        Ecto.Changeset.change(p, %{price: new_price})\n        |\u003e Bakery.Repo.update()\n      end)\n\n      :ok\n  end\nend\n```\n\nResult\n\n![List actions](assets/kaffy_list_actions.png)\n\n`list_actions/1` takes a `conn` and must return a keyword list.\nThe keys must be atoms defining the unique action \"keys\".\nThe 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.\n\nThe `change_price` action is a multi-step action.\nThe defined `:inputs` option will display a popup with a form that contains defined in this option.\n`:inputs` should be a list of maps. Each input must have a `:name` and a `:title`.\nAn optional key in the input map is `:use_select`, which defaults to `false`.\nIf `true`, the input becomes a `select` instead by using a passed in list called `:options`, which is a list of lists formatted like so `[[display, value], [display, value]]`.\nIf `false`, a `:default` value is required for the text input.\nAfter submitting the popup form, the extra values, along with the selected resources, are passed to the `:action` function.\nIn the example above, `change_price/2` will receive the selected products with a map of extra inputs, like: `%{\"new_price\" =\u003e \"3.5\"}` for example.\n\n![MultiStep actions](assets/kaffy_multistep_actions.png)\n\nList actions must return one of the following:\n\n- `:ok` indicating the action was performed successfully.\n- `{:error, custom_error}` to communicate a custom error message to the user where `custom_error` is a string.\n\n### Callbacks\n\nSometimes you need to execute certain actions when creating, updating, or deleting records.\n\nKaffy has your back.\n\nThere are a few callbacks that are called every time you create, update, or delete a record.\n\nThese callbacks are:\n\n- `before_insert/2`\n- `before_update/2`\n- `before_delete/2`\n- `before_save/2`\n- `after_save/2`\n- `after_delete/2`\n- `after_update/2`\n- `after_insert/2`\n\n`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.\n\n- `before_(create|save|update)/2` must return `{:ok, changeset}` to continue.\n- `before_delete/2` must return `{:ok, record}` to continue.\n- All `after_*` functions must return `{:ok, record}` to continue.\n\nTo prevent the chain from continuing and roll back any changes:\n\n- `before_(create|save|update)/2` must return `{:error, changeset}`.\n- `before_delete/2` must return `{:error, record, \"Customized error message}`.\n- All `after_*` functions must return `{:error, record, \"Customized error message\"}`.\n\nWhen creating a new record, the following functions are called in this order:\n\n- `before_insert/2`\n- `before_save/2`\n- inserting the record happens here: `Repo.insert/1`\n- `after_save/2`\n- `after_insert/2`\n\nWhen updating an existing record, the following functions are called in this order:\n\n- `before_update/2`\n- `before_save/2`\n- updating the record happens here: `Repo.update/1`\n- `after_save/2`\n- `after_update/2`\n\nWhen deleting a record, the following functions are called in this order:\n\n- `before_delete/2`\n- deleting the record happens here: `Repo.delete/1`\n- `after_delete/2`\n\nIt'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.\n\n```elixir\ndefmodule MyApp.Blog.PostAdmin do\n  def before_insert(conn, changeset) do\n    case conn.assigns.user.username == \"aesmail\" do\n      true -\u003e {:error, changeset} # aesmail should never create a post\n      false -\u003e {:ok, changeset}\n    end\n  end\n\n  def after_insert(_conn, post) do\n    {:error, post, \"This will prevent posts from being created\"}\n  end\n\n  def before_delete(conn, post) do\n    case conn.assigns.user.role do\n      \"admin\" -\u003e {:ok, post}\n      _ -\u003e {:error, post, \"Only admins can delete posts\"}\n    end\n  end\nend\n```\n\n### Overwrite actions\n\nSometimes you may need to overwrite the way Kaffy is creating, updating, or deleting records.\n\nYou can define you own Admin function to perform those actions. This can be useful if you are creating complex records, importing files, etc...\n\nThe function that can be overwritten are:\n\n- `insert/2`\n- `update/2`\n- `delete/2`\n\n`insert/2`, `update/2` \u0026 `delete/2` functions are passed the current `conn` and a changeset.\nThey must return `{:ok, record}` to continue.\n\n```elixir\ndefmodule MyApp.Blog.PostAdmin do\n  def insert(conn, changeset) do\n    entry = Post.create_complex_post(conn.params)\n    {:ok, entry}\n  end\n\n  def update(conn, changeset) do\n    entry = Post.update_complex_post(conn.params[\"id\"])\n    {:ok, entry}\n  end\n\n  def delete(conn, changeset) do\n    entry = Post.delete_complex_post(conn.params[\"id\"])\n    {:ok, entry}\n  end\nend\n```\n\n### Scheduled Tasks\n\nKaffy supports simple scheduled tasks. Tasks are functions that are run periodically. Behind the scenes, they are put inside `GenServer`s and supervised with a `DynamicSupervisor`.\n\nTo setup scheduled tasks, first define a `task_[task_name]/1` function in your admin module that returns a list of tasks:\n\n```elixir\ndefmodule MyApp.Products.ProductAdmin do\n  def task_products do\n    [\n      %{\n        name: \"Cache Product Count\",\n        initial_value: 0,\n        every: 15,\n        action: fn _v -\u003e\n          count = Bakery.Products.cache_product_count()\n          # \"count\" will be passed to this function in its next run.\n          {:ok, count}\n        end\n      },\n      %{\n        name: \"Delete Fake Products\",\n        every: 60,\n        initial_value: nil,\n        action: fn _ -\u003e\n          Bakery.Products.delete_fake_products()\n          {:ok, nil}\n        end\n      }\n    ]\n  end\nend\n```\n\nOnce this is done, add the admin module to the `scheduled_tasks` option in your config:\n```elixir\nconfig :kaffy,\n  ...\n  scheduled_tasks: [\n    MyApp.Products.ProductAdmin\n  ]\n\n```\n\nA new \"Tasks\" menu item will show up (below the Dashboard item) with your tasks as well as some tiny bits of information about each task like the following image:\n\n![Simple scheduled tasks](assets/kaffy_tasks.png)\n\nThe `task_[task_name]/1` function takes a schema and must return a list of tasks.\n\nA task is a map with the following keys:\n\n- `:name` to hold a short description for the task.\n- `:initial_value` to pass to the task's action in its first run.\n- `:every` to indicate the number of seconds between each run.\n- `:action` to hold an anonymous function with arity/1.\n\nThe `initial_value` is passed to the `action` function in its first run.\n\nThe `action` function must return one of the following values:\n\n- `{:ok, value}` which indicates a successful run. The `value` will be passed to the `action` function in its next run.\n- `{:error, value}` which indicates a failed run. The `value` will be saved and passed again to the `action` function in its next run.\n\nIf the `action` function crashes, the task will be brought back up again in its initial state that is defined in the `task_[task_name]/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.\n\nNote that since scheduled tasks are run with `GenServer`s, they are stored and kept in memory. Having too many scheduled tasks under low memory conditions can cause an out of memory exception.\n\nScheduled tasks should be used for simple, non-critical operations.\n\n## The Driving Points\n\nA few points that encouraged the creation and development of Kaffy:\n\n- Taking contexts into account.\n  - Supporting contexts makes the admin interface better organized.\n- Can handle as many schemas as necessary.\n  - Whether we have 1 schema or 1000 schemas, the admin interface should adapt well.\n- Have a visually pleasant user interface.\n  - This might be subjective.\n- No generators or generated templates.\n  - I believe the less files there are the better. This also means it's easier to upgrade for users when releasing new versions. This might mean some flexibility and customizations will be lost, but it's a trade-off.\n- Existing schemas/contexts shouldn't have to be modified.\n  - I shouldn't have to change my code in order to adapt to the package, the package should adapt to my code.\n- Should be easy to use whether with a new project or with existing projects with a lot of schemas.\n  - Adding kaffy should be as easy for existing projects as it is for new ones.\n- Highly flexible and customizable.\n  - Provide as many configurable options as possible.\n- As few dependencies as possible.\n  - Currently kaffy only depends on Phoenix and Ecto.\n- Simple authorization.\n  - I need to limit access for some admins to some schemas.\n- Sensible, modifiable, default assumptions.\n  - When the package assumes something, this assumption should be sensible and modifiable when needed.\n\n\n## Copyright and License\n\nCopyright (c) 2020 Abdullah Esmail\n\nThis work is free. You can redistribute it and/or modify it under the\nterms of the MIT License. See the [LICENSE.md](./LICENSE.md) file for more details.\n","funding_links":["https://github.com/sponsors/aesmail"],"categories":["Elixir"],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Faesmail%2Fkaffy","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Faesmail%2Fkaffy","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Faesmail%2Fkaffy/lists"}