feat: Basic post design

This commit is contained in:
Robert Prehn 2020-07-27 20:28:41 +00:00
parent 6a09de2a9e
commit e3e00f3db2
1874 changed files with 67127 additions and 154 deletions

@ -1 +0,0 @@
Subproject commit 0dd35bbe6ff20366ab4ddab0069bacf53373002a

View file

@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

View file

@ -0,0 +1,22 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
labels: bug
assignees: aesmail
---
**Versions Used**
Kaffy:
Phoenix:
Elixir:
**What's actually happening?**
**What should happen instead?**
**Screenshots**
If applicable, add screenshots to help explain your problem.

View file

@ -0,0 +1,17 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FEATURE-REQUEST]"
labels: enhancement
assignees: aesmail
---
**Describe the problem you're proposing to solve**
**Describe the solution you'd like**
**Describe alternatives you've considered**
**Additional context**
Add any other context or screenshots about the feature request here.

30
apps/admin/kaffy/.gitignore vendored Normal file
View file

@ -0,0 +1,30 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where third-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Ignore package tarball (built via "mix hex.build").
kaffy-*.tar
# Ignore local node_modules
/node_modules/
.elixir_ls
.DS_Store
.tool-versions

View file

@ -0,0 +1,266 @@
### v0.9.1 (in development)
#### Bug Fixes
- Clicking on the "Select all" checkbox and performing an action wasn't working properly (#129).
- A resource with a `{:array, _}` field type used to crash when rendering the form page (#130).
- Tidbit icons weren't shown properly.
- Schemas with `has_many` or `many_to_many` assocations crashed when trying to save if the schema doesn't have a default `changeset/2` function.
### v0.9.0 (2020-07-02)
#### Breaking change
- If you are defining your resources manually, you need to replace all `:schemas` keys with `:resources`.
#### Bug Fixes
- `map` and JSON fields weren't being properly recognized and saved/updated (regression from v0.8.x).
- Searching a schema which has a `:string` virtual field produced a crash.
- "Next" page link was active even when there was no records to display on the next page.
- belongs_to fields were almost invisible on small screens.
- Schemas without a public `changeset/2` function were crashing due to parameters not being cast properly.
- Searching a resource with a search term that contained a special SQL character (% or _) returned invalid results.
- Multi-word contexts weren't being formatted properly.
#### Enhancements
- Introducing extension modules to add custom html, css, and javascript.
- Custom form fields for specialized functionality.
- List actions now can have an intermediary step for more input from the user.
- Decimal values are displayed properly on the index page.
- Improved layout for mobile screens.
- First column on index page is the first field in your schema.
- Ability to override Kaffy's `insert`, `update`, and `delete` functions to customize how the function works.
- Moved scheduled tasks to their own modules and they have their own option in config.
- Improved alert message styles.
- Much improved pagination UI.
- Ability to customize the query Kaffy uses for the index and show pages.
- A more flexible and customizable way to define resource and admin modules.
- Added a `help_text` option to `form_fields` to display a helpful text next to the field.
#### Contributors for v0.9.0
- Areski Belaid (@areski)
- Axel Clark (@axelclark)
- Adi Purnama (@adipurnama)
- Nicolas Resnikow (@nresni)
- Abdullah Esmail (@aesmail)
### v0.8.1 (2020-06-05)
#### Bug Fixes
- The "Select all" checkbox didn't work properly (thanks @areski).
- Kaffy crashed when opening the page to select a record for the belogns_to association.
#### Enhancements
- UI improvements on the index page (thanks @areski).
- Replace MDI icons with FontAwesome.
### v0.8.0 (2020-06-03)
#### Breaking Changes
- removed `:permission` field option in favor of `:create` and `:update` options for more control and customization.
#### New Features
- ability to add custom links to the side menu.
- ability to add add custom pages.
- ability to order records per column in the index page.
#### Enhancements
- a placeholder value for :map textarea fields to indicate that JSON content is expected.
- enhanced "humanization" of field names in index page.
- improved checkbox form control UI (thanks @areski).
- new and improved design (thanks @areski).
- include checkboxes in index page to clearly indicate records are selectable.
- pagination, filtration, and searching are now bookmarkable with querystring parameters.
- `count` query result is now cached if the table has more than 100,000 records (thanks @areski).
- add option to hide the dashboard menu item.
- add option to change the root url to be something other than the dashboard.
- removed render warnings when running under phoenix 1.5.
- add a much improved date/time picker (thanks @areski).
### v0.7.1 (2020-05-23)
#### Bug Fixes
- kaffy was ignoring the default/custom changeset functions when creating/updating records.
#### Enhancements
- do not show the "Tasks" menu item if there are no tasks (thanks @areski).
- esthetic changes on the index page (thanks @areski).
### v0.7.0 (2020-05-22)
#### New Features
- introducing simple scheduled tasks.
#### Enhancements
- search across assocations.
- improve how autodetected schema names are formatted.
- clicking on the upper left title goes to the website's root "/" (used to go to the dashboard page, which already has a link in the menu).
- fix a few typos in README (thanks @areski).
### v0.6.2 (2020-05-20)
#### Bug Fixes
- multi-word CamelCase schemas weren't being saved properly.
#### Enhancements
- by default, do not include autogenerated fields resource form page.
- order autodetected contexts/schemas alphabetically.
### v0.6.1 (2020-05-19)
#### Bug Fixes
- sometimes the primary key field (id) is treated as an association.
- the popup for selecting a "belongs_to" record was not displaying any records.
- use `fn/0` instead of `fn/1` with `Ecto.Repo.transaction/2` to support ecto 2.x.
### v0.6.0 (2020-05-18)
#### Breaking Changes
- always include the `:kaffy_browser` pipeline to display templates correctly. Please check the minimum configurations - section in the README for more information on how to upgrade.
#### New Features
- support custom actions for a group of selected resources in index page.
#### Bug Fixes
- resource index page table was displayed incorrectly when using a custom pipeline.
- all side menu resources are shown by default including sections that are not currently active.
- side menu does not scroll when there are too many contexts/schemas.
- side menu items all popup at the same time when viewed on small screens.
#### Misc
- added a demo link to the hex package page.
### v0.5.1 (2020-05-17)
#### Enhancements
- add a rich text editor option for form fields (`type: :richtext`).
#### Bug Fixes
- dashboard widgets were displayed improperly on small screens.
### v0.5.0 (2020-05-16)
###### compatible with v0.4.x
#### New Features
- introducing custom widgets in the dashboard.
### v0.4.1 (2020-05-14)
#### New Features
- add custom field filters.
#### Bug Fixes
- sometimes if `index/1` is not defined in the admin module, the index page is empty.
### v0.4.0 (2020-05-13)
#### Breaking Changes
- pass `conn` struct to all callback functions.
#### New Features
- introducing custom actions for single resources.
#### Enhancements
- fix typo in the resource form (thanks @axelclark).
### v0.3.2 (2020-05-12)
#### Bug Fixes
- Kaffy didn't compile with elixir < 1.10 due to the use of `Kernel.is_struct`. It is currently tested with elixir 1.7+
- Sometimes new records couldn't be created if they have `:map` fields.
### v0.3.1 (2020-05-12)
#### Enhancements
- A better way to support foreign key fields with a huge amount of records to select from.
- Retrieve the actual name of the association field from the association struct.
### v0.3.0 (2020-05-11)
#### New Features
- Added ability to delete resources.
- Added resource callbacks when creating, updating, and deleting resources.
#### Bug Fixes
- Don't try to decode map fields when they are empty.
### v0.2.1 (2020-05-10)
### New Features
- Added support for embedded schemas.
- Added support for `:map` fields for json values.
#### Enhancements
- Use the json library configured for phoenix instead of hardcoding `Jason`.
#### Bug Fixes
- Don't crash when the schema has a `has_many` or `has_one` association.
- Don't crash when the schema has a map field or an embedded schema.
### v0.2.0 (2020-05-09)
#### Breaking Changes
- The `:otp_app` config is now required.
#### New Features
- Kaffy will now auto-detect your schemas and admin modules if they're not set explicitly. See the README file for more.
#### Enhancements
- Kaffy now supports phoenix 1.4 and higher.
- Removed some deprecation warnings when compiling kaffy
- Massively simplified configurations. The only required configs now are `otp_app`, `ecto_repo`, and `router`.
### v0.1.2 (2020-05-08)
#### Enhancements
- Much improved UI.
- Some code cleanups.
### v0.1.1 (2020-05-07)
#### Enhancements
- Removed the dependency on `:jason`.
#### Bug Fixes
- Changed `plug :fetch_live_flash` to `plug :fetch_flash` for the default pipeline.

21
apps/admin/kaffy/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Abdullah Esmail
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

976
apps/admin/kaffy/README.md Normal file
View file

@ -0,0 +1,976 @@
<img src="https://opencollective.com/kaffy/tiers/sponsor/badge.svg?label=sponsor&color=brightgreen" />
![What You Get](demos/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
- [Live Demo](#demo)
- [Minimum Requirements](#minimum-requirements)
- [Installation](#installation)
- [Custom Configurations](#configurations)
- [Customize the Side Menu](#side-menu)
- [Customize the Dashboard Page](#dashboard-page)
- [Customize the Index Pages](#index-page)
- [Customize the Form Pages](#form-page)
- [Custom Form Fields](#custom-form-fields)
- [Customize the Queries](#customize-the-queries)
- [Extensions](#extensions)
- [Embedded Schemas and JSON Fields](#embedded-schemas-and-json-fields)
- [Searching Records](#search)
- [Authorizing Access To Resources](#authorization)
- [Custom Changesets](#changesets)
- [Customizing Resource Names](#singular-vs-plural)
- [Custom Actions](#custom-actions)
- [Custom Callbacks When Saving Records](#callbacks)
- [Simple Scheduled Tasks](#scheduled-tasks)
- [The Driving Points Behind Kaffy's Development](#the-driving-points)
## Sponsors
Become a sponsor through Kaffy's [OpenCollective](https://opencollective.com/kaffy) page.
## Demo
[Check out the simple demo here](https://kaffy.gigalixirapp.com/admin/)
## Minimum Requirements
- elixir 1.7.0
- phoenix 1.4.0
## Installation
#### Add `kaffy` as a dependency
```elixir
def deps do
[
{:kaffy, "~> 0.9.0"}
]
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
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:
```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
```
## 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",
hide_dashboard: false,
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]
]
],
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](demos/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 progres 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 wigdet. 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.
- `: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`.
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"},
]
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` or `:top`. `:sub` means it's under the schema sub-item. `:top` means it's displayed at the top of the menu below the "Dashboard" link. 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](demos/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](demos/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](demos/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
```
### 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.
Result
![Customized show/edit page](demos/kaffy_form.png)
Notice that:
- Even though the `status` field is of type `:string`, it is rendered as a `<select>` element with choices.
- The `views` field is rendered as "readonly" because it was set as `:readonly` for the update form.
- `settigns` is an embedded schema. That's why it is rendered as such.
Setting a field's type to `:richtext` will render a rich text editor.
### Custom Form Fields
You can create your own form fields very easily with Kaffy.
Just follow the instructions on how to create a custom type for ecto and add 2 additional functions to the module:
`render_form/5` and `render_index/3`.
Check the below example or a better example on the comments of [this issue](https://github.com/aesmail/kaffy/issues/54).
```elixir
defmodule MyApp.Kaffy.URLField do
use Ecto.Type
def type, do: :string
# casting input from the form and making it "storable" inside the database column (:string)
def cast(url) when is_map(url) do
name = Map.get(url, "one")
link = Map.get(url, "two")
{:ok, ~s(<a href="#{link}">#{name}</a>)}
end
# if the input is not a string, return an error
def cast(_), do: :error
# loading the raw value from the database and turning it into a expected data type for the form
def load(data) when is_binary(data) do
[[_, link]] = Regex.scan(~r/href="(.*)"/, data)
[[_, name]] = Regex.scan(~r/>(.*)</, data)
{:ok, %{"one" => name, "two" => link}}
end
# this function should return the HTML related to rendering the customized form field.
def render_form(_conn, changeset, form, field, _options) do
[
{:safe, ~s(<div class="form-group">)},
Phoenix.HTML.Form.label(form, field, "Web URL"),
Phoenix.HTML.Form.text_input(form, field,
placeholder: "This is a custom field",
class: "form-control",
name: "#{form.name}[#{field}][one]",
id: "#{form.name}_#{field}_one",
value: get_field_value(changeset, field, "one")
),
Phoenix.HTML.Form.text_input(form, field,
placeholder: "This is a custom field",
class: "form-control",
name: "#{form.name}[#{field}][two]",
id: "#{form.name}_#{field}_two",
value: get_field_value(changeset, field, "two")
),
{:safe, ~s(</div>)}
]
end
# this is how the field should be rendered on the index page
def render_index(resource, field, _options) do
case Map.get(resource, field) do
nil ->
""
details ->
name = details["one"]
link = details["two"]
{:safe, ~s(<a href="#{link}">#{name}</a>)}
end
end
defp get_field_value(changeset, field, subfield) do
field_value = Map.get(changeset.data, field)
Map.get(field_value || %{}, subfield, "")
end
end
```
### Customize the Queries
By 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.
```elixir
defmodule MyApp.Blog.PostAdmin do
def custom_index_query(_conn, _schema, query) do
from(r in query, preload: [:tags])
end
def custom_show_query(_conn, _schema, query) do
case user_is_admin?(conn) do
true -> from(r in query, preload: [:history])
false -> query
end
end
end
```
The `custom_index_query/3` function takes a conn, the schema, and the query to customize, and it must return a query.
It is called when fetching the resources for the index page.
The `custom_show_query/3` is identifical to `custom_index_query/3`, but works when fetching a single resource in the show/edit page.
### Extensions
Extensions allow you to define custom css, javascript, and html.
For example, you need to use a specific javascript library or customize the look and feel of Kaffy.
This is where extensions come in handy.
Extensions are elixir modules which special functions.
```elixir
defmodule MyApp.Kaffy.Extension do
def stylesheets(_conn) do
[
{:safe, ~s(<link rel="stylesheet" href="/kaffy/somestyle.css" />)}
]
end
def javascripts(_conn) do
[
{:safe, ~s(<script src="https://example.com/javascript.js"></script>)}
]
end
end
```
There are currently 2 special functions supported in extensions: `stylesheets/1` and `javascripts/1`.
Both functions take a conn and must return a list of safe strings.
`stylesheets/1` will add whatever you include at the end of the `<head>` tag.
`javascripts/1` will add whatever you include there just before the closing `</body>` tag.
Once you have your extension module, you need to add it to the `extensions` list in config:
```elixir
config :kaffy,
# other settings
extensions: [
MyApp.Kaffy.Extension
]
```
You can check [this issue](https://github.com/aesmail/kaffy/issues/54) to see an example which uses extensions with custom fields.
### Embedded Schemas and JSON Fields
Kaffy 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.
### 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.
```elixir
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:
```elixir
# 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`.
```elixir
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.
```elixir
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.
```elixir
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.
```elixir
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](demos/kaffy_resource_actions.png)
`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:
- `{:ok, record}` indicating the action was performed successfully.
- `{:error, changeset}` indicating there was a validation error.
- `{:error, record, custom_error}` to communicate a custom error message to the user where `custom_error` is a string.
#### List Actions
Kaffy also supports actions on a group of resources. You can enable list actions by defining `list_actions/1`.
```elixir
defmodule MyApp.Products.ProductAdmin do
def list_actions(_conn) do
[
change_price: %{
name: "Change the price",
inputs: [
%{name: "new_price", title: "New Price", default: "3"}
],
action: fn _conn, products, params -> change_price(products, params) end
},
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
defp change_price(products, params) do
new_price = Map.get(params, "new_price") |> Decimal.new()
Enum.map(products, fn p ->
Ecto.Changeset.change(p, %{price: new_price})
|> Bakery.Repo.update()
end)
:ok
end
end
```
Result
![List actions](demos/kaffy_list_actions.png)
`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.
The `change_price` action is a multi-step action.
The defined `:inputs` option will display a popup with a form that contains defined in this option.
`:inputs` should be a list of maps. Each input must have a `:name`, a `:title`, and a `:default` value.
After submitting the popup form, the extra values, along with the selected resources, are passed to the `:action` function.
In the example above, `change_price/2` will receive the selected products with a map of extra inputs, like: `%{"new_price" => "3.5"}` for example.
![MultiStep actions](demos/kaffy_multistep_actions.png)
List actions must return one of the following:
- `:ok` indicating the action was performed successfully.
- `{:error, custom_error}` to communicate a custom error message to the user where `custom_error` is a string.
### 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_insert/2`
- `before_update/2`
- `before_delete/2`
- `before_save/2`
- `after_save/2`
- `after_delete/2`
- `after_update/2`
- `after_insert/2`
`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.
- `before_(create|save|update)/2` must return `{:ok, changeset}` to continue.
- `before_delete/2` must return `{:ok, record}` to continue.
- All `after_*` functions must return `{:ok, record}` to continue.
To prevent the chain from continuing and roll back any changes:
- `before_(create|save|update)/2` must return `{:error, changeset}`.
- `before_delete/2` must return `{:error, record, "Customized error message}`.
- All `after_*` functions must return `{:error, record, "Customized error message"}`.
When creating a new record, the following functions are called in this order:
- `before_insert/2`
- `before_save/2`
- inserting the record happens here: `Repo.insert/1`
- `after_save/2`
- `after_insert/2`
When updating an existing record, the following functions are called in this order:
- `before_update/2`
- `before_save/2`
- updating the record happens here: `Repo.update/1`
- `after_save/2`
- `after_update/2`
When deleting a record, the following functions are called in this order:
- `before_delete/2`
- deleting the record happens here: `Repo.delete/1`
- `after_delete/2`
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.
```elixir
defmodule MyApp.Blog.PostAdmin do
def before_insert(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_insert(_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
```
### Overwrite actions
Sometimes you may need to overwrite the way Kaffy is creating, updating, or deleting records.
You can define you own Admin function to perform those actions. This can be useful if you are creating complex records, importing files, etc...
The function that can be overwriten are:
- `insert/2`
- `update/2`
- `delete/2`
`insert/2`, `update/2` & `delete/2` functions are passed the current `conn` and a changeset.
They must return `{:ok, record}` to continue.
```elixir
defmodule MyApp.Blog.PostAdmin do
def insert(conn, changeset) do
entry = Post.create_complex_post(conn.params)
{:ok, entry}
end
def update(conn, changeset) do
entry = Post.update_complex_post(conn.params["id"])
{:ok, entry}
end
def delete(conn, changeset) do
entry = Post.delete_complex_post(conn.params["id"])
{:ok, entry}
end
end
```
### Scheduled Tasks
Kaffy 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`.
To create scheduled tasks, simply define a `scheduled_tasks/1` function in your admin module:
```elixir
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](demos/kaffy_tasks.png)
The `scheduled_tasks/1` function takes a schema and must return a list of tasks.
A task is a map with the following keys:
- `:name` to hold a short description for the task.
- `:initial_value` to pass to the task's action in its first run.
- `:every` to indicate the number of seconds between each run.
- `:action` to hold an anonymous function with arity/1.
The `initial_value` is passed to the `action` function in its first run.
The `action` function must return one of the following values:
- `{:ok, value}` which indicates a successful run. The `value` will be passed to the `action` function in its next run.
- `{:error, value}` which indicates a failed run. The `value` will be saved and passed again to the `action` function in its next run.
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 `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.
Scheduled tasks should be used for simple, non-critical operations.
## The Driving Points
A few points that encouraged the creation and development of Kaffy:
- Taking contexts into account.
- Supporting contexts makes the admin interface better organized.
- Can handle as many schemas as necessary.
- Whether we have 1 schema or 1000 schemas, the admin interface should adapt well.
- Have a visually pleasant user interface.
- This might be subjective.
- No generators or generated templates.
- 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.
- Existing schemas/contexts shouldn't have to be modified.
- I shouldn't have to change my code in order to adapt to the package, the package should adapt to my code.
- Should be easy to use whether with a new project or with existing projects with a lot of schemas.
- Adding kaffy should be as easy for existing projects as it is for new ones.
- Highly flexible and customizable.
- Provide as many configurable options as possible.
- As few dependencies as possible.
- Currently kaffy only depends on phoenix and ecto.
- Simple authorization.
- I need to limit access for some admins to some schemas.
- Sensible, modifiable, default assumptions.
- When the package assumes something, this assumption should be sensible and modifiable when needed.

View file

@ -0,0 +1 @@
theme: jekyll-theme-cayman

View file

@ -0,0 +1,4 @@
use Mix.Config
# Use Jason for JSON parsing in Phoenix
# config :phoenix, :json_library, Jason

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View file

@ -0,0 +1,7 @@
defmodule Kaffy do
@moduledoc false
def hello do
:world
end
end

View file

@ -0,0 +1,15 @@
defmodule Kaffy.Application do
@moduledoc false
use Application
def start(_type, _args) do
children = [
{Kaffy.Scheduler.Supervisor, []},
Kaffy.Cache.Client
]
opts = [strategy: :one_for_one, name: Kaffy.Supervisor]
Supervisor.start_link(children, opts)
end
end

View file

@ -0,0 +1,33 @@
defmodule Kaffy.Cache.Client do
use GenServer
def start_link(args) do
GenServer.start_link(__MODULE__, args, name: KaffyCache)
end
@impl true
def init(_) do
Kaffy.Cache.Table.create_table()
{:ok, %{}}
end
def add_cache(key, suffix, value, expire_after \\ 600) do
GenServer.call(KaffyCache, {:add, key, suffix, value, expire_after})
end
def get_cache(key, suffix) do
GenServer.call(KaffyCache, {:get, key, suffix})
end
@impl true
def handle_call({:add, key, suffix, value, expire_after}, _from, state) do
result = Kaffy.Cache.Table.add_to_cache(key, suffix, value, expire_after)
{:reply, result, state}
end
@impl true
def handle_call({:get, key, suffix}, _from, state) do
result = Kaffy.Cache.Table.get_from_cache(key, suffix)
{:reply, result, state}
end
end

View file

@ -0,0 +1,72 @@
defmodule Kaffy.Cache.Table do
@moduledoc false
@table_name :kaffy_cache
@expire_suffix "_expires"
@doc """
Prepare keys and add key value to the cache
"""
def add_to_cache(key, suffix, value, expire_after \\ 600) do
string_key = stringify_key([key, suffix])
expiration_key = stringify_key([key, suffix, @expire_suffix])
expiration_date = DateTime.utc_now() |> DateTime.add(expire_after)
cache_value(string_key, value)
cache_value(expiration_key, expiration_date)
end
@doc false
defp cache_value(key, value) do
create_table(@table_name)
:ets.insert(@table_name, {key, value})
end
@doc """
Create a new ETS table if undefined
"""
def create_table(name \\ @table_name) do
case :ets.info(name) do
:undefined -> :ets.new(name, [:named_table])
ref -> ref
end
end
@doc """
Get value from the cache ETS table, check if the key is expired
return nil if key doesn't exist or if it's expired
"""
def get_from_cache(key, suffix) do
create_table(@table_name)
final_key = stringify_key([key, suffix])
expiration_key = stringify_key([key, suffix, @expire_suffix])
case :ets.lookup(@table_name, final_key) do
[] ->
nil
[{_k, value}] ->
case :ets.lookup(@table_name, expiration_key) do
[] ->
:ets.delete(@table_name, final_key)
:ets.delete(@table_name, expiration_key)
nil
[{_, expiration_date}] ->
now = DateTime.utc_now()
case DateTime.compare(expiration_date, now) do
:lt ->
:ets.delete(@table_name, final_key)
:ets.delete(@table_name, expiration_key)
nil
_eq_or_gt ->
value
end
end
end
end
@doc false
defp stringify_key(list), do: list |> Enum.map(fn x -> to_string(x) end) |> Enum.join("_")
end

View file

@ -0,0 +1,32 @@
defmodule Kaffy.Pagination do
@moduledoc false
# number of pages to show on the showleft/right of the current page
@pagination_delta 2
def get_pages(0, 0), do: []
def get_pages(current_page, total_page) do
showleft = current_page - @pagination_delta
showright = current_page + @pagination_delta + 1
1..total_page
|> Enum.filter(fn x -> x == 1 || x == total_page || (x >= showleft && x < showright) end)
|> add_dots()
end
defp add_dots(range) do
{added_dots, _acc} =
Enum.map_reduce(range, 0, fn x, last ->
current_page = if adding_page = add_dots_check(x, last), do: [adding_page, x], else: [x]
{current_page, x}
end)
added_dots
|> List.flatten()
end
defp add_dots_check(x, last) when last > 0 and x - last == 2, do: last + 1
defp add_dots_check(x, last) when last > 0 and x - last != 1, do: "..."
defp add_dots_check(_, _), do: nil
end

View file

@ -0,0 +1,405 @@
defmodule Kaffy.ResourceAdmin do
alias Kaffy.ResourceSchema
alias Kaffy.Utils
@moduledoc """
ResourceAdmin modules should be created for every schema you want to customize/configure in Kaffy.
If you have a schema like `MyApp.Products.Product`, you should create an admin module with
name `MyApp.Products.ProductAdmin` and add functions documented in this module to customize the behavior.
All functions are optional.
"""
@doc """
`index/1` takes the schema module and should return a keyword list of fields and
their options.
Supported options are `:name` and `:value`.
Both options can be a string or an anonymous function.
If a fuction is provided, the current entry is passed to it.
If index/1 is not defined, Kaffy will return all the fields of the schema and their default values.
Example:
```elixir
def index(_schema) do
[
id: %{name: "ID", value: fn post -> post.id + 100 end},
title: nil, # this will render the default name for this field (Title) and its default value (post.title)
views: %{name: "Hits", value: fn post -> post.views + 10 end},
published: %{name: "Published?", value: fn post -> published?(post) end},
comment_count: %{name: "Comments", value: fn post -> comment_count(post) end}
]
end
```
"""
def index(resource) do
schema = resource[:schema]
Utils.get_assigned_value_or_default(resource, :index, ResourceSchema.index_fields(schema))
end
@doc """
form_fields/1 takes a schema and returns a keyword list of fields and their options for the new/edit form.
Supported options are:
`:label`, `:type`, `:choices`, and `:permission`
`:type` can be any ecto type in addition to `:file` and `:textarea`
If `:choices` is provided, it must be a keyword list and
the field will be rendered as a `<select>` element regardless of the actual field type.
Setting `:permission` to `:read` will make the field non-editable. It is `:write` by default.
If you want to remove a field from being rendered, just remove it from the list.
If form_fields/1 is not defined, Kaffy will return all the fields with
their default types based on the schema.
Example:
```elixir
def form_fields(_schema) do
[
title: %{label: "Subject"},
slug: nil,
image: %{type: :file},
status: %{choices: [{"Pending", "pending"}, {"Published", "published"}]},
body: %{type: :textarea, rows: 3},
views: %{permission: :read}
]
end
```
"""
def form_fields(resource) do
schema = resource[:schema]
Utils.get_assigned_value_or_default(
resource,
:form_fields,
ResourceSchema.form_fields(schema)
)
|> set_default_field_options(schema)
end
defp set_default_field_options(fields, schema) do
Enum.map(fields, fn {f, o} ->
default_options = Kaffy.ResourceSchema.default_field_options(schema, f)
final_options = Map.merge(default_options, o || %{})
{f, final_options}
end)
end
@doc """
`search_fields/1` takes a schema and must return a list of `:string` fields to search against when typing in the search box.
If `search_fields/1` is not defined, Kaffy will return all the `:string` fields of the schema.
Example:
```elixir
def search_fields(_schema) do
[:title, :slug, :body]
end
```
"""
def search_fields(resource) do
Utils.get_assigned_value_or_default(
resource,
:search_fields,
ResourceSchema.search_fields(resource)
)
end
@doc """
`ordering/1` takes a schema and returns how the entries should be ordered.
If `ordering/1` is not defined, Kaffy will return `[desc: :id]`.
Example:
```elixir
def ordering(_schema) do
[asc: :title]
end
```
"""
def ordering(resource) do
Utils.get_assigned_value_or_default(resource, :ordering, desc: :id)
end
@doc """
`authorized?/2` takes the schema and the current Plug.Conn struct and
should return a boolean value.
Returning false will prevent the access of this resource for the current user/request.
If `authorized?/2` is not defined, Kaffy will return true.
Example:
```elixir
def authorized?(_schema, _conn) do
true
end
```
"""
def authorized?(resource, conn) do
Utils.get_assigned_value_or_default(resource, :authorized?, true, [conn])
end
@doc """
`create_changeset/2` takes the record and the changes and should return a changeset for creating a new record.
If `create_changeset/2` is not defined, Kaffy will try to call `schema.changeset/2`
and if that's not defined, `Ecto.Changeset.change/2` will be called.
Example:
```elixir
def create_changeset(schema, attrs) do
MyApp.Blog.Post.create_changeset(schema, attrs)
end
```
"""
def create_changeset(resource, changes) do
schema = resource[:schema]
schema_struct = schema.__struct__
functions = schema.__info__(:functions)
default =
case Keyword.has_key?(functions, :changeset) do
true ->
schema.changeset(schema_struct, changes)
false ->
cast_fields = Kaffy.ResourceSchema.cast_fields(schema) |> Keyword.keys()
schema_struct
|> Ecto.Changeset.cast(changes, cast_fields)
|> Ecto.Changeset.change(changes)
end
Utils.get_assigned_value_or_default(
resource,
:create_changeset,
default,
[schema.__struct__, changes],
false
)
end
@doc """
`update_changeset/2` takes the record and the changes and should return a changeset for updating an existing record.
If `update_changeset/2` is not defined, Kaffy will try to call `schema.changeset/2`
and if that's not defined, `Ecto.Changeset.change/2` will be called.
Example:
```elixir
def update_changeset(schema, attrs) do
MyApp.Blog.Post.create_changeset(schema, attrs)
end
```
"""
def update_changeset(resource, entry, changes) do
schema = resource[:schema]
functions = schema.__info__(:functions)
default =
case Keyword.has_key?(functions, :changeset) do
true ->
schema.changeset(entry, changes)
false ->
cast_fields = Kaffy.ResourceSchema.cast_fields(schema) |> Keyword.keys()
entry
|> Ecto.Changeset.cast(changes, cast_fields)
|> Ecto.Changeset.change(changes)
Ecto.Changeset.change(entry, changes)
end
Utils.get_assigned_value_or_default(
resource,
:update_changeset,
default,
[entry, changes],
false
)
end
@doc """
This function should return a string for the singular name of a resource.
If `singular_name/1` is not defined, Kaffy will use the name of
the last part of the schema module (e.g. Post in MyApp.Blog.Post)
This is useful for when you have a schema but you want to display its name differently.
If you have "Post" and you want to display "Article" for example.
Example:
```elixir
def singular_name(_schema) do
"Article"
end
```
"""
def singular_name(resource) do
default = humanize_term(resource[:schema])
Utils.get_assigned_value_or_default(resource, :singular_name, default)
end
def humanize_term(term) do
term
|> to_string()
|> String.split(".")
|> Enum.at(-1)
|> Macro.underscore()
|> String.split("_")
|> Enum.map(fn s -> String.capitalize(s) end)
|> Enum.join(" ")
end
@doc """
This is useful for names that cannot be plural by adding an "s" at the end.
Like "Category" => "Categories" or "Person" => "People".
If `plural_name/1` is not defined, Kaffy will use the singular
name and add an "s" to it (e.g. Posts).
Example:
```elixir
def plural_name(_schema) do
"Categories"
end
```
"""
def plural_name(resource) do
default = singular_name(resource) <> "s"
Utils.get_assigned_value_or_default(resource, :plural_name, default)
end
def resource_actions(resource, conn) do
Utils.get_assigned_value_or_default(resource, :resource_actions, nil, [conn], false)
end
def list_actions(resource, conn) do
Utils.get_assigned_value_or_default(resource, :list_actions, nil, [conn], false)
end
def widgets(resource, conn) do
Utils.get_assigned_value_or_default(
resource,
:widgets,
ResourceSchema.widgets(resource),
[conn]
)
end
def collect_widgets(conn) do
Enum.reduce(Kaffy.Utils.contexts(conn), [], fn c, all ->
widgets =
Enum.reduce(Kaffy.Utils.schemas_for_context(conn, c), [], fn {_, resource}, all ->
all ++ Kaffy.ResourceAdmin.widgets(resource, conn)
end)
|> Enum.map(fn widget ->
width = Map.get(widget, :width)
type = widget.type
cond do
is_nil(width) and type == "tidbit" -> Map.put(widget, :width, 3)
is_nil(width) and type == "chart" -> Map.put(widget, :width, 12)
is_nil(width) and type == "flash" -> Map.put(widget, :width, 4)
true -> Map.put_new(widget, :width, 6)
end
end)
all ++ widgets
end)
|> Enum.sort_by(fn w -> Map.get(w, :order, 999) end)
end
def custom_pages(resource, conn) do
Utils.get_assigned_value_or_default(resource, :custom_pages, [], [conn])
end
def collect_pages(conn) do
Enum.reduce(Kaffy.Utils.contexts(conn), [], fn c, all ->
all ++
Enum.reduce(Kaffy.Utils.schemas_for_context(conn, c), [], fn {_, resource}, all ->
all ++ Kaffy.ResourceAdmin.custom_pages(resource, conn)
end)
end)
|> Enum.sort_by(fn p -> Map.get(p, :order, 999) end)
end
def find_page(conn, slug) do
conn
|> collect_pages()
|> Enum.filter(fn p -> p.slug == slug end)
|> Enum.at(0)
end
def custom_links(resource, location \\ nil) do
links = Utils.get_assigned_value_or_default(resource, :custom_links, [])
case location do
nil -> links
:top -> Enum.filter(links, fn l -> Map.get(l, :location, :sub) == :top end)
:sub -> Enum.filter(links, fn l -> Map.get(l, :location, :sub) == :sub end)
end
|> Enum.sort_by(fn l -> Map.get(l, :order, 999) end)
|> Enum.map(fn l -> Map.merge(%{target: "_self", icon: "link", method: :get}, l) end)
end
def collect_links(conn, location) do
contexts = Kaffy.Utils.contexts(conn)
Enum.reduce(contexts, [], fn c, all ->
resources = Kaffy.Utils.schemas_for_context(conn, c)
Enum.reduce(resources, all, fn {_r, options}, all ->
links =
Kaffy.ResourceAdmin.custom_links(options)
|> Enum.filter(fn link -> Map.get(link, :location, :sub) == location end)
all ++ links
end)
end)
|> Enum.sort_by(fn c -> Map.get(c, :order, 999) end)
end
def custom_index_query(conn, resource, query) do
Utils.get_assigned_value_or_default(
resource,
:custom_index_query,
query,
[conn, resource[:schema], query],
false
)
end
def custom_show_query(conn, resource, query) do
Utils.get_assigned_value_or_default(
resource,
:custom_show_query,
query,
[conn, resource[:schema], query],
false
)
end
end

View file

@ -0,0 +1,206 @@
defmodule Kaffy.ResourceCallbacks do
@moduledoc false
alias Kaffy.Utils
def create_callbacks(conn, resource, changes) do
changeset = Kaffy.ResourceAdmin.create_changeset(resource, changes)
repo = Kaffy.Utils.repo()
repo.transaction(fn ->
result =
with {:ok, changeset} <- before_insert(conn, resource, changeset),
{:ok, changeset} <- before_save(conn, resource, changeset),
{:ok, entry} <- exec_insert(conn, resource, changeset),
{:ok, entry} <- after_save(conn, resource, entry),
do: after_insert(conn, resource, entry)
case result do
{:ok, entry} -> entry
{:error, changeset} -> repo.rollback(changeset)
{:error, entry, error} -> repo.rollback({entry, error})
end
end)
end
defp exec_insert(conn, resource, changeset) do
with {:ok, entry} <- insert(conn, resource, changeset) do
{:ok, entry}
else
{:error, :not_found} ->
Kaffy.Utils.repo().insert(changeset)
unexpected_error ->
{:error, unexpected_error}
end
end
def update_callbacks(conn, resource, entry, changes) do
changeset = Kaffy.ResourceAdmin.update_changeset(resource, entry, changes)
repo = Kaffy.Utils.repo()
repo.transaction(fn ->
result =
with {:ok, changeset} <- before_update(conn, resource, changeset),
{:ok, changeset} <- before_save(conn, resource, changeset),
{:ok, entry} <- exec_update(conn, resource, changeset),
{:ok, entry} <- after_save(conn, resource, entry),
do: after_update(conn, resource, entry)
case result do
{:ok, entry} -> entry
{:error, changeset} -> repo.rollback(changeset)
{:error, entry, error} -> repo.rollback({entry, error})
end
end)
end
defp exec_update(conn, resource, changeset) do
with {:ok, entry} <- update(conn, resource, changeset) do
{:ok, entry}
else
{:error, :not_found} ->
Kaffy.Utils.repo().update(changeset)
unexpected_error ->
{:error, unexpected_error}
end
end
def delete_callbacks(conn, resource, entry) do
repo = Kaffy.Utils.repo()
repo.transaction(fn ->
result =
with {:ok, changeset} <- before_delete(conn, resource, entry),
{:ok, entry} <- exec_delete(conn, resource, changeset),
do: after_delete(conn, resource, entry)
case result do
{:ok, entry} -> entry
{:error, changeset} -> repo.rollback(changeset)
{:error, entry, error} -> repo.rollback({entry, error})
end
end)
end
defp before_insert(conn, resource, changeset) do
Utils.get_assigned_value_or_default(
resource,
:before_insert,
{:ok, changeset},
[conn, changeset],
false
)
end
defp insert(conn, resource, changeset) do
Utils.get_assigned_value_or_default(
resource,
:insert,
{:error, :not_found},
[conn, changeset],
false
)
end
defp after_insert(conn, resource, entry) do
Utils.get_assigned_value_or_default(
resource,
:after_insert,
{:ok, entry},
[conn, entry],
false
)
end
defp before_update(conn, resource, changeset) do
Utils.get_assigned_value_or_default(
resource,
:before_update,
{:ok, changeset},
[conn, changeset],
false
)
end
defp before_save(conn, resource, changeset) do
Utils.get_assigned_value_or_default(
resource,
:before_save,
{:ok, changeset},
[conn, changeset],
false
)
end
defp after_save(conn, resource, entry) do
Utils.get_assigned_value_or_default(resource, :after_save, {:ok, entry}, [conn, entry], false)
end
defp update(conn, resource, changeset) do
Utils.get_assigned_value_or_default(
resource,
:update,
{:error, :not_found},
[conn, changeset],
false
)
end
defp after_update(conn, resource, entry) do
Utils.get_assigned_value_or_default(
resource,
:after_update,
{:ok, entry},
[conn, entry],
false
)
end
defp exec_delete(conn, resource, changeset) do
with {:ok, entry} <- delete(conn, resource, changeset) do
{:ok, entry}
else
{:error, :not_found} ->
Kaffy.Utils.repo().delete(changeset)
unexpected_error ->
{:error, unexpected_error}
end
end
defp before_delete(conn, resource, entry) do
changeset = Kaffy.ResourceAdmin.update_changeset(resource, entry, %{})
Utils.get_assigned_value_or_default(
resource,
:before_delete,
{:ok, changeset},
[conn, changeset],
false
)
end
defp delete(conn, resource, changeset) do
Utils.get_assigned_value_or_default(
resource,
:delete,
{:error, :not_found},
[conn, changeset],
false
)
end
defp after_delete(conn, resource, entry) do
# changeset = Kaffy.ResourceAdmin.update_changeset(resource, entry, %{})
Utils.get_assigned_value_or_default(
resource,
:after_delete,
{:ok, entry},
[conn, entry],
false
)
end
end

View file

@ -0,0 +1,54 @@
defmodule Kaffy.ResourceError do
use Phoenix.HTML
def form_error_border_class(form, default_class) do
if Enum.count(form.errors) > 0 do
"border-left-danger"
else
default_class
end
end
def display_errors(conn, form) do
errors =
case length(form.errors) do
0 ->
[]
_x ->
keys =
Keyword.keys(form.errors)
|> Enum.uniq()
|> Enum.filter(fn x -> not is_field_in_form?(x, conn) end)
for field <- keys,
do:
Enum.map(Keyword.get_values(form.errors, field), fn {msg, opts} ->
msg =
if count = opts[:count] do
String.replace(msg, "%{count}", to_string(count))
else
msg
end
content_tag :div, class: "alert alert-danger" do
[
content_tag(:i, "", class: "fa fa-exclamation-circle"),
content_tag(:strong, "Error: "),
content_tag(:span, Kaffy.ResourceAdmin.humanize_term(field) <> " " <> msg)
]
end
end)
end
Enum.reduce(errors, [], fn error, combined ->
Enum.reduce(error, combined, fn e, all -> [e | all] end)
end)
end
defp is_field_in_form?(field, conn) do
# check if field is part of the form, as we are showing inlines errors we don't want to show them twice.
# This is needed as some field error might not be inlines, for instance for a required field from changetset that's not displayed in the form
conn.params[conn.assigns.resource] |> Map.has_key?(Atom.to_string(field))
end
end

View file

@ -0,0 +1,362 @@
defmodule Kaffy.ResourceForm do
use Phoenix.HTML
def form_label_string({field, options}), do: Map.get(options, :label, field)
def form_label_string(field) when is_atom(field), do: field
def form_label(form, field) do
label_text = form_label_string(field)
label(form, label_text)
end
def form_help_text({_field, options}), do: Map.get(options, :help_text, nil)
def form_help_text(field) when is_atom(field), do: nil
def bare_form_field(resource, form, {field, options}) do
options = options || %{}
type = Map.get(options, :type, Kaffy.ResourceSchema.field_type(resource[:schema], field))
permission = Map.get(options, :permission, :write)
choices = Map.get(options, :choices)
cond do
!is_nil(choices) ->
select(form, field, choices, class: "custom-select")
permission == :read ->
content_tag(
:div,
label(form, field, Kaffy.ResourceSchema.kaffy_field_value(resource[:schema], field))
)
true ->
build_html_input(resource[:schema], form, field, type, [])
end
end
def form_field(changeset, form, field, opts \\ [])
def form_field(changeset, form, {field, options}, opts) do
options = options || %{}
type =
Map.get(options, :type, Kaffy.ResourceSchema.field_type(changeset.data.__struct__, field))
opts =
if type == :textarea do
rows = Map.get(options, :rows, 5)
Keyword.put(opts, :rows, rows)
else
opts
end
permission =
case is_nil(changeset.data.id) do
true -> Map.get(options, :create, :editable)
false -> Map.get(options, :update, :editable)
end
choices = Map.get(options, :choices)
cond do
!is_nil(choices) ->
select(form, field, choices, class: "custom-select")
true ->
build_html_input(changeset.data, form, field, type, opts, permission == :readonly)
end
end
def form_field(changeset, form, field, opts) do
type = Kaffy.ResourceSchema.field_type(changeset.data.__struct__, field)
build_html_input(changeset.data, form, field, type, opts)
end
defp build_html_input(schema, form, field, type, opts, readonly \\ false) do
data = schema
{conn, opts} = Keyword.pop(opts, :conn)
opts = Keyword.put(opts, :readonly, readonly)
schema = schema.__struct__
case type do
{:embed, _} ->
embed = Kaffy.ResourceSchema.embed_struct(schema, field)
embed_fields = Kaffy.ResourceSchema.fields(embed)
embed_changeset = Ecto.Changeset.change(Map.get(data, field) || embed.__struct__)
inputs_for(form, field, fn fp ->
[
{:safe, ~s(<div class="card ml-3" style="padding:15px;">)},
Enum.reduce(embed_fields, [], fn f, all ->
content_tag :div, class: "form-group" do
[
[
form_label(fp, f),
form_field(embed_changeset, fp, f, class: "form-control")
]
| all
]
end
end),
{:safe, "</div>"}
]
end)
:id ->
case Kaffy.ResourceSchema.primary_key(schema) == [field] do
true -> text_input(form, field, opts)
false -> text_or_assoc(conn, schema, form, field, opts)
end
:string ->
text_input(form, field, opts)
:richtext ->
opts = Keyword.put(opts, :class, "kaffy-editor")
textarea(form, field, opts)
:textarea ->
textarea(form, field, opts)
:integer ->
number_input(form, field, opts)
:float ->
text_input(form, field, opts)
:decimal ->
text_input(form, field, opts)
t when t in [:boolean, :boolean_checkbox] ->
checkbox_opts = Keyword.put(opts, :class, "custom-control-input")
label_opts = Keyword.put(opts, :class, "custom-control-label")
[
{:safe, ~s(<div class="custom-control custom-checkbox">)},
checkbox(form, field, checkbox_opts),
label(form, field, label_opts),
{:safe, "</div>"}
]
:boolean_switch ->
checkbox_opts = Keyword.put(opts, :class, "custom-control-input")
label_opts = Keyword.put(opts, :class, "custom-control-label")
[
{:safe, ~s(<div class="custom-control custom-switch">)},
checkbox(form, field, checkbox_opts),
label(form, field, label_opts),
{:safe, "</div>"}
]
:map ->
value = Map.get(data, field, "")
value =
cond do
is_map(value) -> Kaffy.Utils.json().encode!(value, escape: :html_safe, pretty: true)
true -> value
end
textarea(form, field, [value: value, rows: 4, placeholder: "JSON Content"] ++ opts)
{:array, _} ->
value =
data
|> Map.get(field, "")
|> Kaffy.Utils.json().encode!(escape: :html_safe, pretty: true)
textarea(form, field, [value: value, rows: 4, placeholder: "JSON Content"] ++ opts)
:file ->
file_input(form, field, opts)
:select ->
select(form, field, opts)
:date ->
flatpickr_date(form, field, opts)
:time ->
flatpickr_time(form, field, opts)
:naive_datetime ->
flatpickr_datetime(form, field, opts)
:naive_datetime_usec ->
flatpickr_datetime_usec(form, field, opts)
:utc_datetime ->
flatpickr_datetime(form, field, opts)
:utc_datetime_usec ->
flatpickr_datetime_usec(form, field, opts)
_ ->
text_input(form, field, opts)
end
end
defp flatpickr_time(form, field, opts) do
flatpickr_generic(form, field, opts, "Select Date...", "flatpickr-wrap-time", "🕒")
end
defp flatpickr_date(form, field, opts) do
flatpickr_generic(form, field, opts, "Select Date...", "flatpickr-wrap-date", "🗓️")
end
defp flatpickr_datetime(form, field, opts) do
flatpickr_generic(form, field, opts, "Select Datetime...", "flatpickr-wrap-datetime")
end
defp flatpickr_datetime_usec(form, field, opts) do
flatpickr_generic(form, field, opts, "Select Datetime...", "flatpickr-wrap-datetime-usec")
end
defp flatpickr_generic(form, field, opts, placeholder, fp_class, icon \\ "📅") do
opts = Keyword.put(opts, :class, "flatpickr-input")
opts = Keyword.put(opts, :class, "form-control")
opts = Keyword.put(opts, :id, "inlineFormInputGroup")
opts = Keyword.put(opts, :placeholder, placeholder)
opts = Keyword.put(opts, :"data-input", "")
[
{:safe, ~s(
<div class="input-group mb-2 flatpickr #{fp_class}">
<div class="input-group-prepend">
<div class="input-group-text" data-clear></div>
</div>
<div class="input-group-prepend">
<div class="input-group-text" data-toggle>#{icon}</div>
</div>
)},
text_input(form, field, opts),
{:safe, "</div>"}
]
end
defp text_or_assoc(conn, schema, form, field, opts) do
actual_assoc =
Enum.filter(Kaffy.ResourceSchema.associations(schema), fn a ->
Kaffy.ResourceSchema.association(schema, a).owner_key == field
end)
|> Enum.at(0)
field_no_id =
case actual_assoc do
nil -> field
_ -> Kaffy.ResourceSchema.association(schema, actual_assoc).field
end
case field_no_id in Kaffy.ResourceSchema.associations(schema) do
true ->
assoc = Kaffy.ResourceSchema.association_schema(schema, field_no_id)
option_count = Kaffy.ResourceQuery.cached_total_count(assoc, true, assoc)
case option_count > 100 do
true ->
target_context = Kaffy.Utils.get_context_for_schema(conn, assoc)
target_resource = Kaffy.Utils.get_schema_key(conn, target_context, assoc)
content_tag :div, class: "input-group" do
[
number_input(form, field,
class: "form-control",
id: field,
aria_describedby: field
),
content_tag :div, class: "input-group-append" do
content_tag :span, class: "input-group-text", id: field do
link(content_tag(:i, "", class: "fas fa-search"),
to:
Kaffy.Utils.router().kaffy_resource_path(
conn,
:index,
target_context,
target_resource,
c: conn.params["context"],
r: conn.params["resource"],
pick: field
),
id: "pick-raw-resource"
)
end
end
]
end
false ->
options = Kaffy.Utils.repo().all(assoc)
fields = Kaffy.ResourceSchema.fields(assoc)
string_fields = Enum.filter(fields, fn {_f, options} -> options.type == :string end)
popular_strings =
string_fields
|> Enum.filter(fn {f, _} -> f in [:name, :title] end)
|> Enum.at(0)
string_field =
case is_nil(popular_strings) do
true -> Enum.at(string_fields, 0) |> elem(0)
false -> elem(popular_strings, 0)
end
select(
form,
field,
Enum.map(options, fn o -> {Map.get(o, string_field, "Resource ##{o.id}"), o.id} end),
class: "custom-select"
)
end
false ->
number_input(form, field, opts)
end
end
def get_field_error(form, field) do
case Keyword.get_values(form.errors, field) do
[{msg, _}] ->
error_msg = Kaffy.ResourceAdmin.humanize_term(field) <> " " <> msg <> "!"
{error_msg, "is-invalid"}
_ ->
{nil, ""}
end
end
def kaffy_input(conn, changeset, form, field, options) do
ft = Kaffy.ResourceSchema.field_type(changeset.data.__struct__, field)
case Kaffy.Utils.is_module(ft) && Keyword.has_key?(ft.__info__(:functions), :render_form) do
true ->
ft.render_form(conn, changeset, form, field, options)
false ->
{error_msg, error_class} = get_field_error(form, field)
help_text = form_help_text({field, options})
content_tag :div, class: "form-group #{error_class}" do
label_tag = if ft != :boolean, do: form_label(form, {field, options}), else: ""
field_tag =
form_field(changeset, form, {field, options},
class: "form-control #{error_class}",
conn: conn
)
field_feeback = [
content_tag :div, class: "invalid-feedback" do
error_msg
end,
content_tag :p, class: "help_text" do
help_text
end
]
[label_tag, field_tag, field_feeback]
end
end
end
end

View file

@ -0,0 +1,39 @@
defmodule Kaffy.ResourceParams do
alias Kaffy.ResourceSchema
def decode_map_fields(resource, schema, params) do
map_fields = ResourceSchema.get_map_fields(schema) |> Enum.map(fn {f, _} -> to_string(f) end)
attrs =
Map.get(params, resource, %{})
|> Enum.map(fn {k, v} ->
case k in map_fields && String.length(v) > 0 do
true -> {k, Kaffy.Utils.json().decode!(v)}
false -> {k, v}
end
end)
|> Map.new()
attrs =
Enum.reduce(ResourceSchema.embeds(schema), attrs, fn e, params ->
embed_schema = ResourceSchema.embed_struct(schema, e)
embed_map_fields =
ResourceSchema.fields(embed_schema)
|> Enum.filter(fn f -> ResourceSchema.field_type(embed_schema, f) == :map end)
Enum.reduce(embed_map_fields, params, fn f, p ->
json_string = get_in(attrs, [to_string(e), to_string(f)])
if json_string && String.length(json_string) > 0 do
json_object = Kaffy.Utils.json().decode!(json_string)
put_in(p, [to_string(e), to_string(f)], json_object)
else
p
end
end)
end)
Map.put(params, resource, attrs)
end
end

View file

@ -0,0 +1,153 @@
defmodule Kaffy.ResourceQuery do
@moduledoc false
import Ecto.Query
def list_resource(conn, resource, params \\ %{}) do
per_page = Map.get(params, "limit", "100") |> String.to_integer()
page = Map.get(params, "page", "1") |> String.to_integer()
search = Map.get(params, "search", "") |> String.trim()
search_fields = Kaffy.ResourceAdmin.search_fields(resource)
filtered_fields = get_filter_fields(params, resource)
ordering = get_ordering(resource, params)
current_offset = (page - 1) * per_page
schema = resource[:schema]
{all, paged} =
build_query(
schema,
search_fields,
filtered_fields,
search,
per_page,
ordering,
current_offset
)
custom_query = Kaffy.ResourceAdmin.custom_index_query(conn, resource, paged)
current_page = Kaffy.Utils.repo().all(custom_query)
do_cache = if search == "" and Enum.empty?(filtered_fields), do: true, else: false
all_count = cached_total_count(schema, do_cache, all)
{all_count, current_page}
end
def get_ordering(resource, params) do
default_ordering = Kaffy.ResourceAdmin.ordering(resource)
default_order_field = Map.get(params, "_of", "nil") |> String.to_existing_atom()
default_order_way = Map.get(params, "_ow", "nil") |> String.to_existing_atom()
case is_nil(default_order_field) or is_nil(default_order_way) do
true -> default_ordering
false -> [{default_order_way, default_order_field}]
end
end
def fetch_resource(conn, resource, id) do
schema = resource[:schema]
id_column = resource[:id_column] || :id
query = from(s in schema, where: ^[{id_column, id}])
custom_query = Kaffy.ResourceAdmin.custom_show_query(conn, resource, query)
Kaffy.Utils.repo().one(custom_query)
end
def fetch_list(_, [""]), do: []
def fetch_list(resource, ids) do
schema = resource[:schema]
from(s in schema, where: s.id in ^ids)
|> Kaffy.Utils.repo().all()
end
def total_count(schema, do_cache, query) do
result =
from(s in query, select: fragment("count(*)"))
|> Kaffy.Utils.repo().one()
if do_cache and result > 100_000 do
Kaffy.Cache.Client.add_cache(schema, "count", result, 600)
end
result
end
def cached_total_count(schema, false, query), do: total_count(schema, false, query)
def cached_total_count(schema, do_cache, query) do
Kaffy.Cache.Client.get_cache(schema, "count") || total_count(schema, do_cache, query)
end
defp get_filter_fields(params, resource) do
schema_fields =
Kaffy.ResourceSchema.fields(resource[:schema]) |> Enum.map(fn {k, _} -> to_string(k) end)
filtered_fields = Enum.filter(params, fn {k, v} -> k in schema_fields and v != "" end)
Enum.map(filtered_fields, fn {name, value} ->
%{name: name, value: value}
end)
end
defp build_query(
schema,
search_fields,
filtered_fields,
search,
per_page,
ordering,
current_offset
) do
query = from(s in schema)
query =
cond do
(is_nil(search_fields) || Enum.empty?(search_fields)) && search == "" ->
query
true ->
term =
search
|> String.replace("%", "\%")
|> String.replace("_", "\_")
term = "%#{term}%"
Enum.reduce(search_fields, query, fn
{association, fields}, q ->
query = from(s in q, join: a in assoc(s, ^association))
Enum.reduce(fields, query, fn f, current_query ->
from([..., r] in current_query, or_where: ilike(field(r, ^f), ^term))
end)
f, q ->
from(s in q, or_where: ilike(field(s, ^f), ^term))
end)
end
query = build_filtered_fields_query(query, filtered_fields)
limited_query =
from(s in query, limit: ^per_page, offset: ^current_offset, order_by: ^ordering)
{query, limited_query}
end
defp build_filtered_fields_query(query, []), do: query
defp build_filtered_fields_query(query, [filter | rest]) do
query =
case filter.value == "" do
true ->
query
false ->
field_name = String.to_existing_atom(filter.name)
from(s in query, where: field(s, ^field_name) == ^filter.value)
end
build_filtered_fields_query(query, rest)
end
end

View file

@ -0,0 +1,315 @@
defmodule Kaffy.ResourceSchema do
@moduledoc false
def primary_key(schema) do
schema.__schema__(:primary_key)
end
def excluded_fields(schema) do
{pk, _, _} = schema.__schema__(:autogenerate_id)
autogenerated = schema.__schema__(:autogenerate)
case length(autogenerated) do
1 ->
[{auto_fields, _}] = autogenerated
[pk] ++ auto_fields
_ ->
[pk]
end
end
def index_fields(schema) do
Keyword.drop(fields(schema), fields_to_be_removed(schema))
end
def form_fields(schema) do
to_be_removed = fields_to_be_removed(schema) ++ [:id, :inserted_at, :updated_at]
Keyword.drop(fields(schema), to_be_removed)
end
def cast_fields(schema) do
to_be_removed =
fields_to_be_removed(schema) ++
get_has_many_associations(schema) ++
get_has_one_assocations(schema) ++
get_many_to_many_associations(schema) ++ [:id, :inserted_at, :updated_at]
Keyword.drop(fields(schema), to_be_removed)
end
def fields(schema) do
schema
|> get_all_fields()
|> reorder_fields(schema)
end
defp get_all_fields(schema) do
schema.__changeset__()
|> Enum.map(fn {k, _} -> {k, default_field_options(schema, k)} end)
end
def default_field_options(schema, field) do
type = field_type(schema, field)
label = Kaffy.ResourceForm.form_label_string(field)
merge_field_options(%{label: label, type: type})
end
def merge_field_options(options) do
default = %{
create: :editable,
update: :editable,
label: nil,
type: nil,
choices: nil
}
Map.merge(default, options || %{})
end
defp fields_to_be_removed(schema) do
# if schema defines belongs_to assocations, remove assoc fields and keep their actual *_id fields.
schema.__changeset__()
|> Enum.reduce([], fn {field, type}, all ->
case type do
{:assoc, %Ecto.Association.BelongsTo{}} ->
[field | all]
{:assoc, %Ecto.Association.Has{cardinality: :many}} ->
[field | all]
{:assoc, %Ecto.Association.Has{cardinality: :one}} ->
[field | all]
_ ->
all
end
end)
end
defp reorder_fields(fields_list, schema) do
[_id, first_field | _fields] = schema.__schema__(:fields)
# this is a "nice" feature to re-order the default fields to put the specified fields at the top/bottom of the form
fields_list
|> reorder_field(first_field, :first)
|> reorder_field(:email, :first)
|> reorder_field(:name, :first)
|> reorder_field(:title, :first)
|> reorder_field(:id, :first)
|> reorder_field(:inserted_at, :last)
|> reorder_field(:updated_at, :last)
# |> reorder_field(Kaffy.ResourceSchema.embeds(schema), :last)
end
defp reorder_field(fields_list, [], _), do: fields_list
defp reorder_field(fields_list, [field | rest], position) do
fields_list = reorder_field(fields_list, field, position)
reorder_field(fields_list, rest, position)
end
defp reorder_field(fields_list, field_name, position) do
if field_name in Keyword.keys(fields_list) do
{field_options, fields_list} = Keyword.pop(fields_list, field_name)
case position do
:first -> [{field_name, field_options}] ++ fields_list
:last -> fields_list ++ [{field_name, field_options}]
end
else
fields_list
end
end
def has_field_filters?(resource) do
admin_fields = Kaffy.ResourceAdmin.index(resource)
fields_with_filters =
Enum.map(admin_fields, fn f -> kaffy_field_filters(resource[:schema], f) end)
Enum.any?(fields_with_filters, fn
{_, filters} -> filters
_ -> false
end)
end
def kaffy_field_filters(_schema, {field, options}) do
{field, Map.get(options || %{}, :filters, false)}
end
def kaffy_field_filters(_, _), do: false
def kaffy_field_name(schema, {field, options}) do
default_name = kaffy_field_name(schema, field)
name = Map.get(options || %{}, :name)
cond do
is_binary(name) -> name
is_function(name) -> name.(schema)
true -> default_name
end
end
def kaffy_field_name(_schema, field) when is_atom(field) do
Kaffy.ResourceAdmin.humanize_term(field)
end
def kaffy_field_value(conn, schema, {field, options}) do
default_value = kaffy_field_value(schema, field)
ft = Kaffy.ResourceSchema.field_type(schema.__struct__, field)
value = Map.get(options || %{}, :value)
cond do
is_function(value) ->
value.(schema)
is_map(value) && Map.has_key?(value, :__struct__) ->
if value.__struct__ in [NaiveDateTime, DateTime, Date, Time] do
value
else
Map.from_struct(value)
|> Map.drop([:__meta__])
|> Kaffy.Utils.json().encode!(escape: :html_safe, pretty: true)
end
Kaffy.Utils.is_module(ft) && Keyword.has_key?(ft.__info__(:functions), :render_index) ->
ft.render_index(conn, schema, field, options)
is_map(value) ->
Kaffy.Utils.json().encode!(value, escape: :html_safe, pretty: true)
is_binary(value) ->
value
true ->
default_value
end
end
def kaffy_field_value(schema, field) when is_atom(field) do
value = Map.get(schema, field, "")
cond do
is_map(value) && Map.has_key?(value, :__struct__) && value.__struct__ == Decimal ->
value
is_map(value) && Map.has_key?(value, :__struct__) ->
if value.__struct__ in [NaiveDateTime, DateTime, Date, Time] do
value
else
Map.from_struct(value)
|> Map.drop([:__meta__])
|> Kaffy.Utils.json().encode!(escape: :html_safe, pretty: true)
end
is_map(value) ->
Kaffy.Utils.json().encode!(value, escape: :html_safe, pretty: true)
is_binary(value) ->
String.slice(value, 0, 140)
true ->
value
end
end
def display_string_fields([], all), do: Enum.reverse(all) |> Enum.join(",")
def display_string_fields([{field, _} | rest], all) do
display_string_fields(rest, [field | all])
end
def display_string_fields([field | rest], all) do
display_string_fields(rest, [field | all])
end
def associations(schema) do
schema.__schema__(:associations)
end
def get_has_many_associations(schema) do
associations(schema)
|> Enum.filter(fn a ->
case association(schema, a) do
%Ecto.Association.Has{cardinality: :many} -> true
_ -> false
end
end)
end
def get_has_one_assocations(schema) do
associations(schema)
|> Enum.filter(fn a ->
case association(schema, a) do
%Ecto.Association.Has{cardinality: :one} -> true
_ -> false
end
end)
end
def get_many_to_many_associations(schema) do
associations(schema)
|> Enum.filter(fn a ->
case association(schema, a) do
%Ecto.Association.ManyToMany{cardinality: :many} -> true
_ -> false
end
end)
end
def association(schema, name) do
schema.__schema__(:association, name)
end
def association_schema(schema, assoc) do
association(schema, assoc).queryable
end
def embeds(schema) do
schema.__schema__(:embeds)
end
def embed(schema, name) do
schema.__schema__(:embed, name)
end
def embed_struct(schema, name) do
embed(schema, name).related
end
def search_fields(resource) do
schema = resource[:schema]
persisted_fields = schema.__schema__(:fields)
Enum.filter(fields(schema), fn f ->
field_name = elem(f, 0)
field_type(schema, f).type in [:string, :textarea, :richtext] &&
field_name in persisted_fields
end)
|> Enum.map(fn {f, _} -> f end)
end
def filter_fields(_), do: nil
def field_type(_schema, {_, type}), do: type
def field_type(schema, field), do: schema.__changeset__() |> Map.get(field, :string)
# def field_type(schema, field), do: schema.__schema__(:type, field)
def get_map_fields(schema) do
get_all_fields(schema)
|> Enum.filter(fn
{_f, options} ->
options.type == :map
f when is_atom(f) ->
f == :map
end)
end
def widgets(_resource) do
[]
end
end

View file

@ -0,0 +1,57 @@
defmodule Kaffy.Routes do
@moduledoc """
Kaffy.Routes must be "used" in your phoenix routes:
```elixir
use Kaffy.Routes, scope: "/admin", pipe_through: [:browser, :authenticate]
```
`:scope` defaults to `"/admin"`
`:pipe_through` defaults to kaffy's `[:kaffy_browser]`
"""
# use Phoenix.Router
defmacro __using__(options \\ []) do
scoped = Keyword.get(options, :scope, "/admin")
custom_pipes = Keyword.get(options, :pipe_through, [])
pipes = [:kaffy_browser] ++ custom_pipes
quote do
pipeline :kaffy_browser do
plug(:accepts, ["html", "json"])
plug(:fetch_session)
plug(:fetch_flash)
plug(:protect_from_forgery)
plug(:put_secure_browser_headers)
end
scope unquote(scoped), KaffyWeb do
pipe_through(unquote(pipes))
get("/", HomeController, :index, as: :kaffy_home)
get("/dashboard", HomeController, :dashboard, as: :kaffy_dashboard)
get("/tasks", TaskController, :index, as: :kaffy_task)
get("/p/:slug", PageController, :index, as: :kaffy_page)
get("/:context/:resource", ResourceController, :index, as: :kaffy_resource)
post("/:context/:resource", ResourceController, :create, as: :kaffy_resource)
post("/:context/:resource/:id/action/:action_key", ResourceController, :single_action,
as: :kaffy_resource
)
post("/:context/:resource/action/:action_key", ResourceController, :list_action,
as: :kaffy_resource
)
get("/:context/:resource/new", ResourceController, :new, as: :kaffy_resource)
get("/:context/:resource/:id", ResourceController, :show, as: :kaffy_resource)
put("/:context/:resource/:id", ResourceController, :update, as: :kaffy_resource)
delete("/:context/:resource/:id", ResourceController, :delete, as: :kaffy_resource)
get("/kaffy/api/:context/:resource", ResourceController, :api, as: :kaffy_api_resource)
end
end
end
end

View file

@ -0,0 +1,19 @@
defmodule Kaffy.Scheduler.Supervisor do
use DynamicSupervisor
def start_link(args) do
result = DynamicSupervisor.start_link(__MODULE__, args, name: KaffyTaskSupervisor)
tasks = Kaffy.Tasks.collect_tasks()
for task <- tasks do
DynamicSupervisor.start_child(KaffyTaskSupervisor, {Kaffy.Scheduler.Task, task})
end
result
end
@impl true
def init(_tasks) do
DynamicSupervisor.init(strategy: :one_for_one)
end
end

View file

@ -0,0 +1,56 @@
defmodule Kaffy.Scheduler.Task do
use GenServer
def start_link(args) do
GenServer.start_link(__MODULE__, args)
end
@impl true
def init(task) do
task =
Map.merge(task, %{
successful: 0,
failure: 0,
failure_value: nil,
last_successful: nil,
last_failure: nil,
current_value: Map.get(task, :initial_value),
started_at: DateTime.utc_now()
})
schedule_work(task)
{:ok, task}
end
@impl true
def handle_info(:perform_task, task) do
task =
case task.action.(task.current_value) do
{:ok, value} ->
task
|> Map.put(:current_value, value)
|> Map.put(:successful, task.successful + 1)
|> Map.put(:last_successful, DateTime.utc_now())
{:error, invalid_value} ->
task
|> Map.put(:failure, task.failure + 1)
|> Map.put(:failure_value, invalid_value)
|> Map.put(:last_failure, DateTime.utc_now())
end
# Reschedule once more
schedule_work(task)
{:noreply, task}
end
@impl true
def handle_call(:info, _, task) do
{:reply, task, task}
end
defp schedule_work(task) do
# In seconds
Process.send_after(self(), :perform_task, task.every * 1000)
end
end

View file

@ -0,0 +1,19 @@
defmodule Kaffy.Tasks do
def collect_tasks() do
Kaffy.Utils.get_task_modules()
|> Enum.map(fn m ->
m.__info__(:functions)
|> Enum.filter(fn {f, _} -> String.starts_with?(to_string(f), "task_") end)
|> Enum.map(fn {f, _} -> apply(m, f, []) end)
end)
|> List.flatten()
end
def tasks_info() do
children = DynamicSupervisor.which_children(KaffyTaskSupervisor)
Enum.map(children, fn {_, p, _, _} ->
GenServer.call(p, :info)
end)
end
end

View file

@ -0,0 +1,405 @@
defmodule Kaffy.Utils do
@moduledoc false
@doc """
Returns the :admin_title config if present, otherwise returns "Kaffy"
"""
@spec title() :: String.t()
def title() do
env(:admin_title, "Kaffy")
end
@doc """
Returns the JSON package used by phoenix configs. If no such config exists, raise an exception.
"""
@spec json() :: atom()
def json() do
case Application.get_env(:phoenix, :json_library) do
nil ->
raise "A json package must be configured. For example: config :phoenix, :json_library, Jason"
j ->
j
end
end
@doc """
Returns the Repo from Kaffy configs. If it is not present, raise an exception.
"""
@spec repo() :: atom()
def repo() do
case env(:ecto_repo) do
nil -> raise "Must define :ecto_repo for Kaffy to work properly."
r -> r
end
end
@doc """
Returns the version of the provided app.
Example:
> get_version_of(:phoenix)
> "1.5.3"
"""
@spec get_version_of(atom()) :: String.t()
def get_version_of(package) do
{:ok, version} = :application.get_key(package, :vsn)
to_string(version)
end
@doc """
Returns true when phoenix's version has the same prefix as the provided argument, false otherwise.
Example:
phoenix_version?("1.4.")
> true # returns true for all phoenix 1.4.x versions
"""
@spec phoenix_version?(String.t()) :: boolean()
def phoenix_version?(prefix) do
version = get_version_of(:phoenix)
String.starts_with?(version, prefix)
end
@doc """
Returns the router helper module from the configs. Raises if the router isn't specified.
"""
@spec router() :: atom()
def router() do
case env(:router) do
nil -> raise "The :router config must be specified: config :kaffy, router: MyAppWeb.Router"
r -> r
end
|> Module.concat(Helpers)
end
@doc """
Returns a keyword list of all the resources specified in config.exs.
If the :resources key isn't specified, this function will load all application modules,
filters the schemas modules, combine them into a keyword list, and returns that list.
Example:
```elixir
full_resources()
[
categories: [
schemas: [
category: [
schema: Bakery.Categories.Category,
admin: Bakery.Categories.CategoryAdmin
]
]
]
]
```
"""
@spec full_resources(Plug.Conn.t()) :: [any()]
def full_resources(conn) do
case env(:resources) do
f when is_function(f) -> f.(conn)
l when is_list(l) -> l
_ -> setup_resources()
end
end
@doc """
Returns a list of contexts as atoms.
Example:
iex> contexts()
[:blog, :products, :users]
"""
@spec contexts(Plug.Conn.t()) :: [atom()]
def contexts(conn) do
full_resources(conn)
|> Enum.map(fn {context, _options} -> context end)
end
@doc """
Returns the context name based on the configs.
Example:
```elixir
context = [
categories: [
schemas: [
category: [schema: Bakery.Categories.Category]
]
]
]
context_name(context)
> "Categories"
context = [
categories: [
name: "Types",
schemas: [
category: [schema: Bakery.Categories.Category]
]
]
]
context_name(context)
> "Types"
```
"""
@spec context_name(Plug.Conn.t(), list()) :: String.t()
def context_name(conn, context) do
default = Kaffy.ResourceAdmin.humanize_term(context)
get_in(full_resources(conn), [context, :name]) || default
end
@doc """
Returns the context list from the configs for a specific schema.
This is usually used to get the name or other information of the schema context.
"""
@spec get_context_for_schema(Plug.Conn.t(), module()) :: list()
def get_context_for_schema(conn, schema) do
contexts(conn)
|> Enum.filter(fn c ->
schemas = Enum.map(schemas_for_context(conn, c), fn {_k, v} -> Keyword.get(v, :schema) end)
schema in schemas
end)
|> Enum.at(0)
end
def get_schema_key(conn, context, schema) do
schemas_for_context(conn, context)
|> Enum.reduce([], fn {k, v}, keys ->
case schema == Keyword.get(v, :schema) do
true -> [k | keys]
false -> keys
end
end)
|> Enum.at(0)
end
@doc """
Returns the resource entry from the configs.
Example:
iex> get_resource("blog", "post")
[schema: MyApp.Blog.Post, admin: MyApp.Blog.PostAdmin]
"""
@spec get_resource(Plug.Conn.t(), String.t(), String.t()) :: list()
def get_resource(conn, context, resource) do
{context, resource} = convert_to_atoms(context, resource)
get_in(full_resources(conn), [context, :resources, resource])
end
@doc """
Returns all the schemas for the given context.
Example:
iex> schemas_for_context("blog")
[
post: [schema: MyApp.Blog.Post, admin: MyApp.Blog.PostAdmin],
comment: [schema: MyApp.Blog.Comment],
]
"""
@spec schemas_for_context(Plug.Conn.t(), list()) :: list()
def schemas_for_context(conn, context) do
context = convert_to_atom(context)
get_in(full_resources(conn), [context, :resources])
end
# @doc """
# Get the schema for the provided context/resource combination.
# iex> schema_for_resource("blog", "post")
# MyApp.Blog.Post
# """
# @spec schema_for_resource(String.t(), String.t()) :: module()
# def schema_for_resource(context, resource) do
# {context, resource} = convert_to_atoms(context, resource)
# get_in(full_resources(), [context, :schemas, resource, :schema])
# end
# @doc """
# Like schema_for_resource/2, but returns the admin module, or nil if an admin module doesn't exist.
# iex> admin-fro_resource("blog", "post")
# MyApp.Blog.PostAdmin
# """
# @spec admin_for_resource(String.t(), String.t()) :: module() | nil
# def admin_for_resource(context, resource) do
# {context, resource} = convert_to_atoms(context, resource)
# get_in(full_resources(), [context, :schemas, resource, :admin])
# end
def get_assigned_value_or_default(resource, function, default, params \\ [], add_schema \\ true) do
admin = resource[:admin]
schema = resource[:schema]
arguments = if add_schema, do: [schema] ++ params, else: params
case !is_nil(admin) && has_function?(admin, function) do
true -> apply(admin, function, arguments)
false -> default
end
end
@doc """
Returns true if the given module implements the given function, false otherwise.
iex> has_function?(MyApp.Blog.PostAdmin, :form_fields)
true
"""
@spec has_function?(module(), atom()) :: boolean()
def has_function?(admin, func) do
functions = admin.__info__(:functions)
Keyword.has_key?(functions, func)
end
@doc """
Returns true if `thing` is a module, false otherwise.
"""
@spec is_module(module()) :: boolean()
def is_module(thing), do: is_atom(thing) && function_exported?(thing, :__info__, 1)
@doc """
Returns whether the dashbaord link should be displayed or hidden. Default behavior is to show the dashboard link.
This option is taken from the :hide_dashboard config option.
iex> show_dashboard?()
true
"""
@spec show_dashboard?() :: boolean()
def show_dashboard?() do
env(:hide_dashboard, false) == false
end
@doc """
Takes a conn struct and returns the route to display as the root route.
This option can be optionally provided in the configs. If it is not provided, the default route is the dashboard.
Options are:
- [kaffy: :dashboard]
- [schema: ["blog", "post"]]
- [page: "my-custom-page"]
iex> home_page(conn)
"/admin/dashboard"
"""
@spec home_page(Plug.Conn.t()) :: String.t()
def home_page(conn) do
case env(:home_page, kaffy: :dashboard) do
[kaffy: :dashboard] ->
router().kaffy_dashboard_path(conn, :dashboard)
[schema: [context, resource]] ->
router().kaffy_resource_path(conn, :index, context, resource)
[page: slug] ->
router().kaffy_page_path(conn, :index, slug)
end
end
def extensions(conn) do
exts = env(:extensions, [])
stylesheets =
Enum.map(exts, fn ext ->
case function_exported?(ext, :stylesheets, 1) do
true -> ext.stylesheets(conn)
false -> []
end
end)
javascripts =
Enum.map(exts, fn ext ->
case function_exported?(ext, :javascripts, 1) do
true -> ext.javascripts(conn)
false -> []
end
end)
%{stylesheets: stylesheets, javascripts: javascripts}
end
defp env(key, default \\ nil) do
Application.get_env(:kaffy, key, default)
end
defp convert_to_atoms(context, resource) do
{convert_to_atom(context), convert_to_atom(resource)}
end
defp convert_to_atom(string) do
if is_binary(string), do: String.to_existing_atom(string), else: string
end
defp setup_resources do
otp_app = env(:otp_app)
{:ok, mods} = :application.get_key(otp_app, :modules)
get_schemas(mods)
|> build_resources()
end
defp get_schemas(mods) do
Enum.filter(mods, fn m ->
functions = m.__info__(:functions)
Keyword.has_key?(functions, :__schema__) && Map.has_key?(m.__struct__, :__meta__)
end)
end
defp build_resources(schemas) do
Enum.reduce(schemas, [], fn schema, resources ->
schema_module =
to_string(schema)
|> String.split(".")
context_module =
schema_module
|> Enum.reverse()
|> tl()
|> Enum.reverse()
|> Enum.join(".")
context_name =
schema_module
|> Enum.at(-2)
|> Macro.underscore()
|> String.to_atom()
schema_name_string =
schema_module
|> Enum.at(-1)
schema_name =
schema_name_string
|> Macro.underscore()
|> String.to_atom()
schema_admin = String.to_atom("#{context_module}.#{schema_name_string}Admin")
schema_options =
case function_exported?(schema_admin, :__info__, 1) do
true -> [schema: schema, admin: schema_admin]
false -> [schema: schema]
end
humanized_context = Kaffy.ResourceAdmin.humanize_term(context_name)
resources = Keyword.put_new(resources, context_name, name: humanized_context, resources: [])
resources = put_in(resources, [context_name, :resources, schema_name], schema_options)
existing_schemas = get_in(resources, [context_name, :resources]) |> Enum.sort()
put_in(resources, [context_name, :resources], existing_schemas)
end)
|> Enum.sort()
end
def get_task_modules() do
env(:scheduled_tasks, [])
end
end

View file

@ -0,0 +1,13 @@
defmodule KaffyWeb.HomeController do
@moduledoc false
use Phoenix.Controller, namespace: KaffyWeb
def index(conn, _params) do
redirect(conn, to: Kaffy.Utils.home_page(conn))
end
def dashboard(conn, _params) do
render(conn, "index.html", layout: {KaffyWeb.LayoutView, "app.html"})
end
end

View file

@ -0,0 +1,20 @@
defmodule KaffyWeb.PageController do
@moduledoc false
use Phoenix.Controller, namespace: KaffyWeb
def index(conn, %{"slug" => slug}) do
case Kaffy.ResourceAdmin.find_page(conn, slug) do
nil ->
conn
|> put_flash(:error, "The page you are trying to visit does not exist")
|> redirect(to: Kaffy.Utils.router().kaffy_home_path(conn, :index))
page ->
conn
|> put_layout({KaffyWeb.LayoutView, "app.html"})
|> put_view(page.view)
|> render(page.template, Map.get(page, :assigns, []))
end
end
end

View file

@ -0,0 +1,397 @@
defmodule KaffyWeb.ResourceController do
@moduledoc false
use Phoenix.Controller, namespace: KaffyWeb
use Phoenix.HTML
alias Kaffy.Pagination
def index(
conn,
%{
"context" => context,
"resource" => resource,
"c" => _target_context,
"r" => _target_resource,
"pick" => _field
} = params
) do
my_resource = Kaffy.Utils.get_resource(conn, context, resource)
case can_proceed?(my_resource, conn) do
false ->
unauthorized_access(conn)
true ->
fields = Kaffy.ResourceAdmin.index(my_resource)
{filtered_count, entries} = Kaffy.ResourceQuery.list_resource(conn, my_resource, params)
items_per_page = Map.get(params, "limit", "100") |> String.to_integer()
page = Map.get(params, "page", "1") |> String.to_integer()
has_next = round(filtered_count / items_per_page) > page
next_class = if has_next, do: "", else: " disabled"
has_prev = page >= 2
prev_class = if has_prev, do: "", else: " disabled"
list_pages = Pagination.get_pages(page, ceil(filtered_count / items_per_page))
render(conn, "pick_resource.html",
layout: {KaffyWeb.LayoutView, "bare.html"},
context: context,
resource: resource,
fields: fields,
my_resource: my_resource,
filtered_count: filtered_count,
page: page,
has_next_page: has_next,
next_class: next_class,
has_prev_page: has_prev,
prev_class: prev_class,
list_pages: list_pages,
entries: entries,
params: params
)
end
end
def index(conn, %{"context" => context, "resource" => resource} = params) do
my_resource = Kaffy.Utils.get_resource(conn, context, resource)
case can_proceed?(my_resource, conn) do
false ->
unauthorized_access(conn)
true ->
fields = Kaffy.ResourceAdmin.index(my_resource)
{filtered_count, entries} = Kaffy.ResourceQuery.list_resource(conn, my_resource, params)
items_per_page = Map.get(params, "limit", "100") |> String.to_integer()
page = Map.get(params, "page", "1") |> String.to_integer()
has_next = round(filtered_count / items_per_page) > page
next_class = if has_next, do: "", else: " disabled"
has_prev = page >= 2
prev_class = if has_prev, do: "", else: " disabled"
list_pages = Pagination.get_pages(page, ceil(filtered_count / items_per_page))
render(conn, "index.html",
layout: {KaffyWeb.LayoutView, "app.html"},
context: context,
resource: resource,
fields: fields,
my_resource: my_resource,
filtered_count: filtered_count,
page: page,
has_next_page: has_next,
next_class: next_class,
has_prev_page: has_prev,
prev_class: prev_class,
list_pages: list_pages,
entries: entries,
params: params
)
end
end
def show(conn, %{"context" => context, "resource" => resource, "id" => id}) do
my_resource = Kaffy.Utils.get_resource(conn, context, resource)
schema = my_resource[:schema]
resource_name = Kaffy.ResourceAdmin.singular_name(my_resource)
case can_proceed?(my_resource, conn) do
false ->
unauthorized_access(conn)
true ->
if entry = Kaffy.ResourceQuery.fetch_resource(conn, my_resource, id) do
changeset = Ecto.Changeset.change(entry)
render(conn, "show.html",
layout: {KaffyWeb.LayoutView, "app.html"},
changeset: changeset,
context: context,
resource: resource,
my_resource: my_resource,
resource_name: resource_name,
schema: schema,
entry: entry
)
else
put_flash(conn, :error, "The resource you are trying to visit does not exist!")
|> redirect(
to: Kaffy.Utils.router().kaffy_resource_path(conn, :index, context, resource)
)
end
end
end
def update(conn, %{"context" => context, "resource" => resource, "id" => id} = params) do
my_resource = Kaffy.Utils.get_resource(conn, context, resource)
schema = my_resource[:schema]
params = Kaffy.ResourceParams.decode_map_fields(resource, schema, params)
resource_name = Kaffy.ResourceAdmin.singular_name(my_resource) |> String.capitalize()
case can_proceed?(my_resource, conn) do
false ->
unauthorized_access(conn)
true ->
entry = Kaffy.ResourceQuery.fetch_resource(conn, my_resource, id)
changes = Map.get(params, resource, %{})
case Kaffy.ResourceCallbacks.update_callbacks(conn, my_resource, entry, changes) do
{:ok, entry} ->
conn = put_flash(conn, :success, "#{resource_name} saved successfully")
save_button = Map.get(params, "submit", "Save")
case save_button do
"Save" ->
conn
|> put_flash(:success, "#{resource_name} saved successfully")
|> redirect(
to: Kaffy.Utils.router().kaffy_resource_path(conn, :index, context, resource)
)
"Save and add another" ->
conn
|> put_flash(:success, "#{resource_name} saved successfully")
|> redirect(
to: Kaffy.Utils.router().kaffy_resource_path(conn, :new, context, resource)
)
"Save and continue editing" ->
conn
|> put_flash(:success, "#{resource_name} saved successfully")
|> redirect_to_resource(context, resource, entry)
end
{:error, %Ecto.Changeset{} = changeset} ->
conn =
put_flash(
conn,
:error,
"A problem occurred while trying to save this #{resource}"
)
render(conn, "show.html",
layout: {KaffyWeb.LayoutView, "app.html"},
changeset: changeset,
context: context,
resource: resource,
my_resource: my_resource,
resource_name: resource_name,
schema: schema,
entry: entry
)
{:error, {entry, error}} when is_binary(error) ->
conn = put_flash(conn, :error, error)
changeset = Ecto.Changeset.change(entry)
render(conn, "show.html",
layout: {KaffyWeb.LayoutView, "app.html"},
changeset: changeset,
context: context,
resource: resource,
my_resource: my_resource,
resource_name: resource_name,
schema: schema,
entry: entry
)
end
end
end
def new(conn, %{"context" => context, "resource" => resource}) do
my_resource = Kaffy.Utils.get_resource(conn, context, resource)
resource_name = Kaffy.ResourceAdmin.singular_name(my_resource)
case can_proceed?(my_resource, conn) do
false ->
unauthorized_access(conn)
true ->
changeset = Kaffy.ResourceAdmin.create_changeset(my_resource, %{}) |> Map.put(:errors, [])
render(conn, "new.html",
layout: {KaffyWeb.LayoutView, "app.html"},
changeset: changeset,
context: context,
resource: resource,
resource_name: resource_name,
my_resource: my_resource
)
end
end
def create(conn, %{"context" => context, "resource" => resource} = params) do
my_resource = Kaffy.Utils.get_resource(conn, context, resource)
params = Kaffy.ResourceParams.decode_map_fields(resource, my_resource[:schema], params)
changes = Map.get(params, resource, %{})
resource_name = Kaffy.ResourceAdmin.singular_name(my_resource)
case can_proceed?(my_resource, conn) do
false ->
unauthorized_access(conn)
true ->
case Kaffy.ResourceCallbacks.create_callbacks(conn, my_resource, changes) do
{:ok, entry} ->
case Map.get(params, "submit", "Save") do
"Save" ->
put_flash(conn, :success, "Created a new #{resource_name} successfully")
|> redirect(
to: Kaffy.Utils.router().kaffy_resource_path(conn, :index, context, resource)
)
"Save and add another" ->
conn
|> put_flash(:success, "#{resource_name} saved successfully")
|> redirect(
to: Kaffy.Utils.router().kaffy_resource_path(conn, :new, context, resource)
)
"Save and continue editing" ->
put_flash(conn, :success, "Created a new #{resource_name} successfully")
|> redirect_to_resource(context, resource, entry)
end
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "new.html",
layout: {KaffyWeb.LayoutView, "app.html"},
changeset: changeset,
context: context,
resource: resource,
resource_name: resource_name,
my_resource: my_resource
)
{:error, {entry, error}} when is_binary(error) ->
changeset = Ecto.Changeset.change(entry)
conn
|> put_flash(:error, error)
|> render("new.html",
layout: {KaffyWeb.LayoutView, "app.html"},
changeset: changeset,
context: context,
resource: resource,
resource_name: resource_name,
my_resource: my_resource
)
end
end
end
def delete(conn, %{"context" => context, "resource" => resource, "id" => id}) do
my_resource = Kaffy.Utils.get_resource(conn, context, resource)
case can_proceed?(my_resource, conn) do
false ->
unauthorized_access(conn)
true ->
entry = Kaffy.ResourceQuery.fetch_resource(conn, my_resource, id)
case Kaffy.ResourceCallbacks.delete_callbacks(conn, my_resource, entry) do
{:ok, _deleted} ->
put_flash(conn, :success, "The record was deleted successfully")
|> redirect(
to: Kaffy.Utils.router().kaffy_resource_path(conn, :index, context, resource)
)
{:error, %Ecto.Changeset{} = _changeset} ->
put_flash(
conn,
:error,
"A database-related issue prevented this record from being deleted."
)
|> redirect_to_resource(context, resource, entry)
{:error, {entry, error}} when is_binary(error) ->
put_flash(conn, :error, error)
|> redirect_to_resource(context, resource, entry)
end
end
end
def single_action(conn, %{
"context" => context,
"resource" => resource,
"id" => id,
"action_key" => action_key
}) do
my_resource = Kaffy.Utils.get_resource(conn, context, resource)
entry = Kaffy.ResourceQuery.fetch_resource(conn, my_resource, id)
actions = Kaffy.ResourceAdmin.resource_actions(my_resource, conn)
action_key = String.to_existing_atom(action_key)
[action_record] = Keyword.get_values(actions, action_key)
case action_record.action.(conn, entry) do
{:ok, entry} ->
conn = put_flash(conn, :success, "Action performed successfully")
redirect_to_resource(conn, context, resource, entry)
{:error, _} ->
conn = put_flash(conn, :error, "A validation error occurred")
redirect_to_resource(conn, context, resource, entry)
{:error, _, error_msg} ->
conn = put_flash(conn, :error, error_msg)
redirect_to_resource(conn, context, resource, entry)
end
end
def list_action(
conn,
%{"context" => context, "resource" => resource, "action_key" => action_key} = params
) do
my_resource = Kaffy.Utils.get_resource(conn, context, resource)
action_key = String.to_existing_atom(action_key)
ids = Map.get(params, "ids", "") |> String.split(",")
entries = Kaffy.ResourceQuery.fetch_list(my_resource, ids)
actions = Kaffy.ResourceAdmin.list_actions(my_resource, conn)
[action_record] = Keyword.get_values(actions, action_key)
kaffy_inputs = Map.get(params, "kaffy-input", %{})
result =
case Map.get(action_record, :inputs, []) do
[] -> action_record.action.(conn, entries)
_ -> action_record.action.(conn, entries, kaffy_inputs)
end
case result do
:ok ->
put_flash(conn, :success, "Action performed successfully")
|> redirect(to: Kaffy.Utils.router().kaffy_resource_path(conn, :index, context, resource))
{:error, error_msg} ->
put_flash(conn, :error, error_msg)
|> redirect(to: Kaffy.Utils.router().kaffy_resource_path(conn, :index, context, resource))
end
end
# def export(conn, %{"context" => context, "resource" => resource}) do
# my_resource = Kaffy.Utils.get_resource(conn, context, resource)
# end
defp can_proceed?(resource, conn) do
Kaffy.ResourceAdmin.authorized?(resource, conn)
end
defp unauthorized_access(conn) do
conn
|> put_flash(:error, "You are not authorized to access that page")
|> redirect(to: Kaffy.Utils.router().kaffy_home_path(conn, :index))
end
defp redirect_to_resource(conn, context, resource, entry) do
redirect(conn,
to:
Kaffy.Utils.router().kaffy_resource_path(
conn,
:show,
context,
resource,
entry.id
)
)
end
end

View file

@ -0,0 +1,9 @@
defmodule KaffyWeb.TaskController do
@moduledoc false
use Phoenix.Controller, namespace: KaffyWeb
def index(conn, _params) do
render(conn, "index.html")
end
end

View file

@ -0,0 +1,10 @@
<div class="col-md-<%= @widget.width %> stretch-card grid-margin">
<div class="card bg-<%= @widget.type %> card-img-holder text-white">
<div class="card-body">
<img src="/kaffy/assets/images/dashboard/circle.svg" class="card-img-absolute" alt="circle-image" />
<h4 class="font-weight-normal mb-3"><%= @widget.title %> <i class="fas fa-<%= @widget.icon %> float-right"></i></h4>
<h2 class="mb-5"><%= @widget.content %></h2>
<h6 class="card-text"><%= @widget.subcontent %></h6>
</div>
</div>
</div>

View file

@ -0,0 +1,17 @@
<div class="col-md-<%= @widget.width %> grid-margin stretch-card">
<div class="card shadow">
<div class="card-body kaffy-chart">
<div class="values" style="display:none;">
<span class="x-axis"><%= Enum.join(@widget.content.x, ",") %></span>
<span class="y-axis"><%= Enum.join(@widget.content.y, ",") %></span>
<span class="y-title"><%= @widget.content.y_title %></span>
</div>
<div class="clearfix">
<h4 class="card-title float-left"><%= @widget.title %></h4>
</div>
<%# <div class="kaffy-chart-container" style="position:relative;height:200px;"> %>
<canvas id="<%= :crypto.strong_rand_bytes(6) |> Base.url_encode64() %>" class="mt-2"></canvas>
<%# </div> %>
</div>
</div>
</div>

View file

@ -0,0 +1,9 @@
<div class="col-md-5 grid-margin stretch-card">
<div class="card shadow">
<div class="card-body">
<h4 class="card-title">Traffic Sources</h4>
<canvas id="traffic-chart"></canvas>
<div id="traffic-chart-legend" class="rounded-legend legend-vertical legend-bottom-left pt-4"></div>
</div>
</div>
</div>

View file

@ -0,0 +1,13 @@
<div class="col-md-<%= @widget.width %> grid-margin stretch-card">
<div class="card shadow">
<div class="card-header">
<h4><%= @widget.title %></h4>
</div>
<div class="card-body">
<p class="font-weight-bold text-black"><%= @widget.content %> <span class="float-right"><%= @widget.percentage %>%</span></p>
<div class="progress">
<div class="progress-bar bg-danger" role="progressbar" style="width: <%= @widget.percentage %>%" aria-valuenow="<%= @widget.percentage %>" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,10 @@
<div class="col-md-<%= @widget.width %> grid-margin stretch-card">
<div class="card shadow">
<div class="card-header py-3">
<h4><%= @widget.title %></h4>
</div>
<div class="card-body">
<p class="card-text text-justify"><%= @widget.content %></p>
</div>
</div>
</div>

View file

@ -0,0 +1,8 @@
<div class="col-md-<%= @widget.width %> grid-margin stretch-card">
<div class="card shadow border-left-success">
<div class="card-body">
<p class="font-weight-normal mb-1 text-success"><strong><%= @widget.title %></strong> <i class="fa fa-<%= @widget.icon %> float-right"></i></p>
<h2 class="mt-1"><%= @widget.content %></h2>
</div>
</div>
</div>

View file

@ -0,0 +1,47 @@
<div class="page-header">
<h3 class="page-title">Dashboard </h3>
<nav aria-label="breadcrumb">
<ul class="breadcrumb">
<li class="breadcrumb-item active" aria-current="page">
<span>
Overview <i class="fas fa-feather icon-sm text-primary align-middle"></i>
</span>
</li>
</ul>
</nav>
</div>
<% widgets = Kaffy.ResourceAdmin.collect_widgets(@conn) %>
<%= if Enum.empty?(widgets) do %>
<div class="row mt-3">
<div class="col-md-12 text-center">
<h4>A powerfully simple admin package for phoenix applications.</h4>
<h4>You can add widgets to this page by defining <code>widgets/2</code> in your admin modules.</h4>
</div>
</div>
<% else %>
<div class="row mt-1 row-cols-1 row-cols-md-2 row-cols-sm-1 row-cols-xs-1">
<%= for widget <- widgets do %>
<%= if widget.type == "text" do %>
<%= render KaffyWeb.HomeView, "_text.html", widget: widget %>
<% end %>
<%= if widget.type == "chart" do %>
<%= render KaffyWeb.HomeView, "_chart.html", widget: widget %>
<% end %>
<%= if widget.type == "progress" do %>
<%= render KaffyWeb.HomeView, "_progress.html", widget: widget %>
<% end %>
<%= if widget.type == "tidbit" do %>
<%= render KaffyWeb.HomeView, "_tidbit.html", widget: widget %>
<% end %>
<% end %>
</div>
<% end %>

View file

@ -0,0 +1,170 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title><%= Kaffy.Utils.title() %> - Kaffy</title>
<link rel="stylesheet" href="/kaffy/assets/vendors/fontawesome/css/all.css">
<link rel="stylesheet" href="/kaffy/assets/vendors/css/vendor.bundle.base.css">
<link rel="stylesheet" href="/kaffy/assets/scss/style.css">
<link rel="stylesheet" href="/kaffy/assets/vendors/flatpickr/flatpickr.min.css">
<link rel="stylesheet" href="/kaffy/assets/css/kaffy.css">
<link rel="shortcut icon" href="/kaffy/assets/images/favicon/favicon-32x32.png" />
<%= for css <- Kaffy.Utils.extensions(@conn).stylesheets do %>
<%= css %>
<% end %>
<script src="/kaffy/assets/vendors/js/vendor.bundle.base.js"></script>
</head>
<body>
<div class="container-scroller">
<nav class="navbar default-layout-navbar col-lg-12 col-12 p-0 fixed-top d-flex flex-row">
<div class="text-center navbar-brand-wrapper d-flex align-items-center justify-content-center">
<%= link to: Kaffy.Utils.router().kaffy_home_path(@conn, :index), class: "navbar-brand brand-logo" do %>
<img src="/kaffy/assets/images/logo.png" alt="logo" />
<% end %>
<%= link to: Kaffy.Utils.router().kaffy_home_path(@conn, :index), class: "navbar-brand brand-logo-mini" do %>
<img src="/kaffy/assets/images/logo-mini.png" alt="logo" />
<% end %>
</div>
<div class="navbar-menu-wrapper d-flex align-items-stretch">
<button class="navbar-toggler navbar-toggler align-self-center" type="button" data-toggle="minimize">
<span class="fas fa-bars"></span>
</button>
<button class="navbar-toggler navbar-toggler-right d-lg-none align-self-center" type="button" data-toggle="offcanvas">
<span class="fas fa-bars"></span>
</button>
</div>
</nav>
<div class="container-fluid page-body-wrapper">
<nav class="sidebar sidebar-offcanvas" id="sidebar">
<ul class="nav">
<%= if Kaffy.Utils.show_dashboard?() do %>
<li class="nav-item<%= if @conn.assigns[:context] == nil do %> active<% end %>">
<%= link to: Kaffy.Utils.router().kaffy_dashboard_path(@conn, :dashboard), class: "nav-link" do %>
<span class="menu-title">Dashboard</span>
<i class="fas fa-home menu-icon"></i>
<% end %>
</li>
<% end %>
<% tasks = Kaffy.Tasks.tasks_info() %>
<%= if not Enum.empty?(tasks) do %>
<li class="nav-item">
<%= link to: Kaffy.Utils.router().kaffy_task_path(@conn, :index), class: "nav-link" do %>
<span class="menu-title">Tasks</span>
<i class="fas fa-clock menu-icon"></i>
<% end %>
</li>
<% end %>
<%= for custom_link <- Kaffy.ResourceAdmin.collect_links(@conn, :top) do %>
<li class="nav-item">
<%= link to: custom_link.url, method: custom_link.method, class: "nav-link", target: custom_link.target do %>
<span class="menu-title"><%= custom_link.name %></span>
<i class="fas fa-<%= custom_link.icon %> menu-icon"></i>
<% end %>
</li>
<% end %>
<% custom_pages = Kaffy.ResourceAdmin.collect_pages(@conn) %>
<%= if !Enum.empty?(custom_pages) do %>
<li class="nav-item">
<a class="nav-link collapsed" href="#kaffy-pages" data-toggle="collapse" aria-expanded="false" aria-controls="kaffy-pages">
<span class="menu-title">Pages</span>
<i class="menu-arrow"></i>
<i class="fas fa-bars menu-icon"></i>
</a>
<div class="collapse" id="kaffy-pages">
<ul class="nav flex-column sub-menu">
<%= for custom_page <- custom_pages do %>
<li class="nav-item"><%= link custom_page.name, to: Kaffy.Utils.router().kaffy_page_path(@conn, :index, custom_page.slug), class: "nav-link" %></li>
<% end %>
</ul>
</div>
</li>
<% end %>
<%= for context <- Kaffy.Utils.contexts(@conn) do %>
<li class="nav-item<%= if @conn.assigns[:context] == to_string(context) do %> active<% end %>">
<a class="nav-link <%= if @conn.assigns[:context] != to_string(context) do %> collapsed<% end %>"
href="#<%= context %>-context" data-toggle="collapse" aria-expanded="false" aria-controls="<%= context %>-context">
<span class="menu-title"><%= Kaffy.Utils.context_name(@conn, context) %></span>
<i class="menu-arrow"></i>
<i class="fas fa-bars menu-icon"></i>
</a>
<div class="collapse<%= if @conn.assigns[:context] == to_string(context) do %> show<% end %>" id="<%= context %>-context">
<ul class="nav flex-column sub-menu">
<%= for {resource, options} <- Kaffy.Utils.schemas_for_context(@conn, context) do %>
<%= if Kaffy.ResourceAdmin.authorized?(options, @conn) do %>
<li class="nav-item"><%= link Kaffy.ResourceAdmin.plural_name(options), to: Kaffy.Utils.router().kaffy_resource_path(@conn, :index, context, resource), class: "nav-link" %></li>
<%= for custom_link <- Kaffy.ResourceAdmin.custom_links(options, :sub) do %>
<li class="nav-item"><%= link custom_link.name, to: custom_link.url, class: "nav-link", target: custom_link.target %></li>
<% end %>
<% end %>
<% end %>
</ul>
</div>
</li>
<% end %>
</ul>
</nav>
<div class="main-panel">
<div class="content-wrapper">
<%= if get_flash(@conn, :success) do %>
<div class="alert alert-success">
<i class="fa fa-check"></i><strong>Success: </strong> <%= get_flash(@conn, :success) %>
</div>
<% end %>
<%= if get_flash(@conn, :info) do %>
<div class="alert alert-info">
<i class="fa fa-info-circle"></i><strong>Info: </strong> <%= get_flash(@conn, :info) %>
</div>
<% end %>
<%= if get_flash(@conn, :warning) do %>
<div class="alert alert-warning">
<i class="fa fa-exclamation-triangle"></i><strong>Warning: </strong> <%= get_flash(@conn, :warning) %>
</div>
<% end %>
<%= if get_flash(@conn, :error) do %>
<div class="alert alert-danger">
<i class="fa fa-exclamation-circle"></i><strong>Error: </strong> <%= get_flash(@conn, :error) %>
</div>
<% end %>
<%= if Kaffy.Utils.phoenix_version?("1.4.") do %>
<%= render(@view_module, @view_template, assigns) %>
<% else %>
<%= @inner_content %>
<% end %>
</div>
<footer class="footer">
<div class="d-sm-flex justify-content-center justify-content-sm-between">
<span class="text-muted text-center text-sm-left d-block d-sm-inline-block">Copyright © 2020 Kaffy. All rights reserved.</span>
</div>
</footer>
</div>
</div>
</div>
<script src="/kaffy/assets/vendors/flatpickr/flatpickr.min.js"></script>
<script src="/kaffy/assets/vendors/chart.js/Chart.min.js"></script>
<script src="/kaffy/assets/vendors/js/ckeditor.js"></script>
<script src="/kaffy/assets/js/off-canvas.js"></script>
<script src="/kaffy/assets/js/hoverable-collapse.js"></script>
<script src="/kaffy/assets/js/misc.js"></script>
<script src="/kaffy/assets/js/select-all-checkbox.js?v=20"></script>
<script src="/kaffy/assets/js/phoenix_html.js"></script>
<script src="/kaffy/assets/js/dashboard.js?v=20"></script>
<%= for js <- Kaffy.Utils.extensions(@conn).javascripts do %>
<%= js %>
<% end %>
</body>
</html>

View file

@ -0,0 +1,181 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Kaffy Admin</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
<style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
body {
font-size: .875rem;
}
.feather {
width: 16px;
height: 16px;
vertical-align: text-bottom;
}
/*
* Sidebar
*/
.sidebar {
position: fixed;
top: 0;
bottom: 0;
left: 0;
z-index: 100; /* Behind the navbar */
padding: 48px 0 0; /* Height of navbar */
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
}
.sidebar-sticky {
position: relative;
top: 0;
height: calc(100vh - 48px);
padding-top: .5rem;
overflow-x: hidden;
overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
}
@supports ((position: -webkit-sticky) or (position: sticky)) {
.sidebar-sticky {
position: -webkit-sticky;
position: sticky;
}
}
.sidebar .nav-link {
font-weight: 500;
color: #333;
}
.sidebar .nav-link .feather {
margin-right: 4px;
color: #999;
}
.sidebar .nav-link.active {
color: #007bff;
}
.sidebar .nav-link:hover .feather,
.sidebar .nav-link.active .feather {
color: inherit;
}
.sidebar-heading {
font-size: .75rem;
text-transform: uppercase;
}
/*
* Content
*/
[role="main"] {
padding-top: 133px; /* Space for fixed navbar */
}
@media (min-width: 768px) {
[role="main"] {
padding-top: 48px; /* Space for fixed navbar */
}
}
/*
* Navbar
*/
.navbar-brand {
padding-top: .75rem;
padding-bottom: .75rem;
font-size: 1rem;
background-color: rgba(0, 0, 0, .25);
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25);
}
.navbar .form-control {
padding: .75rem 1rem;
border-width: 0;
border-radius: 0;
}
.form-control-dark {
color: #fff;
background-color: rgba(255, 255, 255, .1);
border-color: rgba(255, 255, 255, .1);
}
.form-control-dark:focus {
border-color: transparent;
box-shadow: 0 0 0 3px rgba(255, 255, 255, .25);
}
</style>
<!-- Custom styles for this template -->
</head>
<body>
<nav class="navbar navbar-dark fixed-top bg-dark flex-md-nowrap p-0 shadow">
<%= link Kaffy.Utils.title(), to: Kaffy.Utils.router().kaffy_home_path(@conn, :index), class: "navbar-brand col-sm-3 col-md-2 mr-0" %>
<ul class="navbar-nav px-3">
<li class="nav-item text-nowrap">
<a class="nav-link" href="#">Sign out</a>
</li>
</ul>
</nav>
<div class="container-fluid">
<div class="row">
<nav class="col-md-2 d-md-block bg-light sidebar">
<div class="sidebar-sticky">
<ul class="nav flex-column">
<%= for context <- Kaffy.Utils.contexts(@conn) do %>
<li class="nav-item">
<a name="<%= context %>" class="nav-link"><strong><%= Kaffy.Utils.context_name(@conn, context) %></strong></a>
<ul style="list-style:none;">
<%= for {resource, options} <- Kaffy.Utils.schemas_for_context(@conn, context) do %>
<%= if Kaffy.ResourceAdmin.authorized?(options, @conn) do %>
<li class="nav-item">
<%= link Kaffy.ResourceAdmin.plural_name(options), to: Kaffy.Utils.router().kaffy_resource_path(@conn, :index, context, resource), class: "nav-link" %>
</li>
<% end %>
<% end %>
</ul>
</li>
<% end %>
</ul>
</div>
</nav>
<main role="main" class="col-md-9 ml-sm-auto col-lg-10 px-4 mt-3">
<%= if get_flash(@conn, :info) do %><p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p><% end %>
<%= if get_flash(@conn, :error) do %><p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p><% end %>
<%= @inner_content %>
</main>
</div>
</div>
<script src="https://code.jquery.com/jquery-3.4.1.slim.min.js" integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js" integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6" crossorigin="anonymous"></script>
</html>

View file

@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title><%= Kaffy.Utils.title() %> - Kaffy</title>
<link rel="stylesheet" href="/kaffy/assets/vendors/fontawesome/css/all.css">
<link rel="stylesheet" href="/kaffy/assets/vendors/css/vendor.bundle.base.css">
<link rel="stylesheet" href="/kaffy/assets/scss/style.css">
<link rel="stylesheet" href="/kaffy/assets/vendors/flatpickr/flatpickr.min.css">
<link rel="stylesheet" href="/kaffy/assets/css/kaffy.css">
<link rel="shortcut icon" href="/kaffy/assets/images/favicon/favicon-32x32.png" />
<%= for css <- Kaffy.Utils.extensions(@conn).stylesheets do %>
<%= css %>
<% end %>
<script src="/kaffy/assets/vendors/js/vendor.bundle.base.js"></script>
</head>
<body>
<div class="container-scroller">
<nav class="navbar default-layout-navbar col-lg-12 col-12 p-0 fixed-top d-flex flex-row">
<div class="text-center navbar-brand-wrapper d-flex align-items-center justify-content-center">
<%= link to: Kaffy.Utils.router().kaffy_home_path(@conn, :index), class: "navbar-brand brand-logo" do %>
<img src="/kaffy/assets/images/logo.png" alt="logo" />
<% end %>
<%= link to: Kaffy.Utils.router().kaffy_home_path(@conn, :index), class: "navbar-brand brand-logo-mini" do %>
<img src="/kaffy/assets/images/logo-mini.png" alt="logo" />
<% end %>
</div>
<div class="navbar-menu-wrapper d-flex align-items-stretch">
<button class="navbar-toggler navbar-toggler align-self-center" type="button" data-toggle="minimize">
<span class="fas fa-bars"></span>
</button>
<button class="navbar-toggler navbar-toggler-right d-lg-none align-self-center" type="button" data-toggle="offcanvas">
<span class="fas fa-bars"></span>
</button>
</div>
</nav>
<div class="container-fluid page-body-wrapper">
<div class="main-panel">
<div class="content-wrapper">
<%= if Kaffy.Utils.phoenix_version?("1.4.") do %>
<%= render(@view_module, @view_template, assigns) %>
<% else %>
<%= @inner_content %>
<% end %>
</div>
<footer class="footer">
<div class="d-sm-flex justify-content-center justify-content-sm-between">
<span class="text-muted text-center text-sm-left d-block d-sm-inline-block">Copyright © 2020 Kaffy. All rights reserved.</span>
</div>
</footer>
</div>
</div>
</div>
<script src="/kaffy/assets/vendors/js/vendor.bundle.base.js"></script>
<script src="/kaffy/assets/vendors/flatpickr/flatpickr.min.js"></script>
<script src="/kaffy/assets/vendors/chart.js/Chart.min.js"></script>
<script src="/kaffy/assets/vendors/js/ckeditor.js"></script>
<script src="/kaffy/assets/js/off-canvas.js"></script>
<script src="/kaffy/assets/js/hoverable-collapse.js"></script>
<script src="/kaffy/assets/js/misc.js"></script>
<script src="/kaffy/assets/js/phoenix_html.js"></script>
<script src="/kaffy/assets/js/dashboard.js"></script>
</body>
</html>

View file

@ -0,0 +1,30 @@
<thead class="thead-dark">
<tr>
<%= for field <- @fields do %>
<th><%= Kaffy.ResourceSchema.kaffy_field_name(@my_resource[:schema], field) %></th>
<% end %>
</tr>
<%= if Kaffy.ResourceSchema.has_field_filters?(@my_resource) do %>
<tr>
<%= for {field, index} <- Enum.with_index(@fields) do %>
<% {field, filters} = Kaffy.ResourceSchema.kaffy_field_filters(@my_resource[:schema], field) %>
<%= if filters do %>
<th class="bg-light">
<select class="kaffy-filter custom-select" id="kaffy-field-<%= index %>" data-field-name="<%= Kaffy.ResourceSchema.kaffy_field_name(@my_resource[:schema], field) %>">
<option value="">All</option>
<%= for {human, machine} <- filters do %>
<option value="<%= machine %>"><%= human %></option>
<% end %>
</select>
</th>
<% else %>
<th class="bg-light"></th>
<% end %>
<% end %>
</tr>
<% end %>
</thead>
<tbody>
</tbody>

View file

@ -0,0 +1,25 @@
<table class="table table-striped">
<thead>
<%= render KaffyWeb.ResourceView, "_table_header.html", my_resource: @my_resource, fields: @fields, params: @params %>
</thead>
<tbody>
<%= for entry <- @entries do %>
<tr>
<td>
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input select-item kaffy-resource-checkbox" id="kaffy-select-<%= entry.id %>" name="resource" value="<%= entry.id %>"/>
<label class="custom-control-label" for="kaffy-select-<%= entry.id %>"></label>
</div>
</td>
<%= for {field, index} <- Enum.with_index(@fields) do %>
<%= if index == 0 do %>
<td><%= link Kaffy.ResourceSchema.kaffy_field_value(@conn, entry, field), to: Kaffy.Utils.router().kaffy_resource_path(@conn, :show, @context, @resource, entry) %></td>
<% else %>
<td><%= Kaffy.ResourceSchema.kaffy_field_value(@conn, entry, field) %></td>
<% end %>
<% end %>
</tr>
<% end %>
</tbody>
</table>

View file

@ -0,0 +1,41 @@
<tr class="bg-light">
<th>
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input select-all kaffy-resource-checkbox" id="kaffy-select-all" name="all-resources" value=""/>
<label class="custom-control-label" for="kaffy-select-all"></label>
</div>
</th>
<%= for field <- @fields do %>
<% field_name = Kaffy.ResourceSchema.kaffy_field_name(@my_resource[:schema], field) |> String.upcase() %>
<% [{order_way, order_field}] = Kaffy.ResourceQuery.get_ordering(@my_resource, @params) %>
<th>
<%= if order_field == elem(field, 0) do %>
<a name="<%= field_name %>" class="kaffy-order-field" data-order="<%= if order_way == :desc do %>asc<% else %>desc<% end %>" data-field="<%= elem(field, 0) %>"><%= field_name %> <i class="fas fa-<%= if order_way == :desc do %>arrow-down<% else %>arrow-up<% end %>"></i><a>
<% else %>
<a name="<%= field_name %>" class="kaffy-order-field" data-order="asc" data-field="<%= elem(field, 0) %>"><%= field_name %><a>
<% end %>
</th>
<% end %>
</tr>
<%= if Kaffy.ResourceSchema.has_field_filters?(@my_resource) do %>
<tr>
<th class="bg-light"></th>
<%= for {field, index} <- Enum.with_index(@fields) do %>
<% {field, filters} = Kaffy.ResourceSchema.kaffy_field_filters(@my_resource[:schema], field) %>
<%= if filters do %>
<th class="bg-light">
<select class="kaffy-filter custom-select" id="kaffy-field-<%= index %>" data-field-name="<%= field %>">
<option value="">All</option>
<%= for {human, machine} <- filters do %>
<option value="<%= machine %>"<%= if Map.get(@params, to_string(field)) == to_string(machine) do %> selected<% end %>><%= human %></option>
<% end %>
</select>
</th>
<% else %>
<th class="bg-light"></th>
<% end %>
<% end %>
</tr>
<% end %>

View file

@ -0,0 +1,127 @@
<div class="col-lg-12 grid-margin stretch-card">
<div class="card shadow mb-4">
<div class="card-header">
<div class="row justify-content-between">
<div class="col-auto mr-auto">
<h3>
<%= Kaffy.ResourceAdmin.plural_name(@my_resource) %><br/>
<span class="badge badge-secondary">(~ <%= @filtered_count %> records)</span>
<div id="checkbox-selected-count" class="checkbox-selected-count float-right"></div>
</h3>
</div>
<div class="col-auto">
<%= link to: Kaffy.Utils.router().kaffy_resource_path(@conn, :new, @context, @resource), class: "btn btn-outline-primary" do %>
<i class="fas fa-plus"></i>
New <%= Kaffy.ResourceAdmin.singular_name(@my_resource) %>
<% end %>
</div>
</div>
</div>
<div class="card-body table-responsive w-100">
<div class="card-description">
<div class="row">
<div class="col-auto mr-auto">
<input id="kaffy-search-field" type="text" name="search" value="<%= Map.get(@params, "search", "") %>" class="form-control bg-transparent" placeholder="Search <%= Kaffy.ResourceAdmin.plural_name(@my_resource) %>...">
</div>
<div class="col-auto">
<%= if list_actions = Kaffy.ResourceAdmin.list_actions(@my_resource, @conn) do %>
<div class="btn-group">
<button type="button" class="btn dropdown-toggle btn-sm btn-secondary" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fas fa-wrench"></i> <span class="d-none d-md-inline">Actions</span>
</button>
<div class="dropdown-menu">
<%= for {action_key, options} <- list_actions do %>
<% extra_inputs = Map.get(options, :inputs, []) %>
<%= if !Enum.empty?(extra_inputs) do %>
<button type="button" data-toggle="modal" class="dropdown-item" data-target="#<%= action_key %>_modal">
<%= options.name %>
</button>
<% else %>
<%= form_tag(Kaffy.Utils.router().kaffy_resource_path(@conn, :list_action, @context, @resource, to_string(action_key)), method: :post, class: "list-action", id: "#{action_key}_form") %>
<input type="submit" name="submit" value="<%= options.name %>" class="dropdown-item kaffy-list-action-submit" id="<%= action_key %>_submit" />
</form>
<% end %>
<% end %>
</div>
</div>
<% end %>
</div>
</div>
</div>
<%= render KaffyWeb.ResourceView, "_table.html", conn: @conn, fields: @fields, context: @context, resource: @resource, my_resource: @my_resource, entries: @entries, params: @params %>
</div>
<div class="index-pagination">
<% params = Map.to_list(@params) |> Enum.map(fn {k, v} -> {String.to_existing_atom(k), v} end) %>
<nav aria-label="index-list-navigation">
<ul class="pagination">
<li class="page-item <%= @prev_class %>">
<%= link "« Previous", to: Kaffy.Utils.router().kaffy_resource_path(@conn, :index, @context, @resource, Keyword.merge(params, page: @page - 1)), class: "page-link" %>
</li>
<%= for page_elem <- @list_pages do %>
<li class="page-item <%= if page_elem == @page do %>active<% end %> <%= if page_elem == "..." do %>disabled<% end %>">
<%= link page_elem, to: Kaffy.Utils.router().kaffy_resource_path(@conn, :index, @context, @resource, Keyword.merge(params, page: page_elem)), class: "page-link" %>
</li>
<% end %>
<li class="page-item <%= @next_class %>">
<%= link "Next »", to: Kaffy.Utils.router().kaffy_resource_path(@conn, :index, @context, @resource, Keyword.merge(params, page: @page + 1)), class: "page-link" %>
</li>
</ul>
</nav>
</div>
</div>
</div>
<form id="kaffy-filters-form" method="get" style="display:none;">
<%= if Kaffy.ResourceSchema.has_field_filters?(@my_resource) do %>
<%= for field <- @fields do %>
<% {field, filters} = Kaffy.ResourceSchema.kaffy_field_filters(@my_resource[:schema], field) %>
<%= if filters do %>
<input id="custom-filter-<%= field %>" type="hidden" name="<%= field %>" value="<%= Map.get(@params, to_string(field), "") %>" />
<% end %>
<% end %>
<% end %>
<input id="kaffy-filter-search" type="hidden" name="search" value="<%= Map.get(@params, "search", "") %>" />
<input id="kaffy-filter-page" type="hidden" name="page" value="<%= Map.get(@params, "page", "1") %>" />
<input id="kaffy-filter-limit" type="hidden" name="limit" value="<%= Map.get(@params, "limit", "100") %>" />
<input id="kaffy-order-field" type="hidden" name="<%= to_string(:_of) %>" value="<%= Map.get(@params, "_of", "id") %>" />
<input id="kaffy-order-way" type="hidden" name="<%= to_string(:_ow) %>" value="<%= Map.get(@params, "_ow", "desc") %>" />
</form>
<% list_actions = Kaffy.ResourceAdmin.list_actions(@my_resource, @conn) || [] %>
<%= for {action_key, options} <- list_actions do %>
<% extra_inputs = Map.get(options, :inputs, []) %>
<%= if !Enum.empty?(extra_inputs) do %>
<%= form_tag(Kaffy.Utils.router().kaffy_resource_path(@conn, :list_action, @context, @resource, to_string(action_key)), method: :post, class: "list-action", id: "#{action_key}_form") %>
<div class="modal fade" id="<%= action_key %>_modal" tabindex="-1" role="dialog" aria-labelledby="<%= action_key %>_modal_label" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="<%= action_key %>_modal_label"><%= options.name %></h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<%= for extra <- extra_inputs do %>
<div class="form-group">
<label><%= extra.title %></label>
<input type="text" data-title="<%= extra.title %>" name="kaffy-input[<%= extra.name %>]" value="<%= extra.default %>" class="form-control kaffy-list-action-input" />
</div>
<% end %>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary kaffy-list-action-submit" id="<%= action_key %>_submit">Continue</button>
</div>
</div>
</div>
</div>
<% end %>
</form>
<% end %>

View file

@ -0,0 +1,34 @@
<%= for error <- Kaffy.ResourceError.display_errors(@conn, @changeset) do %>
<div><%= error %></div>
<% end %>
<div class="mt-3 grid-margin stretch-card">
<div class="card shadow <%= Kaffy.ResourceError.form_error_border_class(@changeset, "border-left-primary")%>">
<div class="card-header">
<div class="row justify-content-between">
<div class="col-auto mr-auto">
<h1 class="mt-4">New <%= Kaffy.ResourceAdmin.singular_name(@my_resource) %></h1>
</div>
</div>
</div>
<div class="card-body">
<%= f = form_for(@changeset, Kaffy.Utils.router().kaffy_resource_path(@conn, :create, @context, @resource), method: :post, multipart: true) %>
<%= for {field, options} <- Kaffy.ResourceAdmin.form_fields(@my_resource) do %>
<%= if options.create != :hidden do %>
<%= Kaffy.ResourceForm.kaffy_input @conn, @changeset, f, field, options %>
<% end %>
<% end %>
<div class="form-group">
<%= link "Back", to: Kaffy.Utils.router().kaffy_resource_path(@conn, :index, @context, @resource), class: "btn btn-sm btn-light" %> &nbsp;
<input type="submit" name="submit" value="Save" class="btn btn-sm btn-primary" />
<input type="submit" name="submit" value="Save and add another" class="btn btn-sm btn-primary" />
<input type="submit" name="submit" value="Save and continue editing" class="btn btn-sm btn-primary" />
</div>
</form>
</div>
</div>
</div>

View file

@ -0,0 +1,43 @@
<div id="pick-resource" style="display:none;">
<div id="pick-field-name"><%= @conn.params["pick"] %></div>
</div>
<div class="col-lg-12 grid-margin stretch-card">
<div class="card shadow mb-4">
<div class="card-body table-responsive w-100">
<h4><%= Kaffy.ResourceAdmin.plural_name(@my_resource) %> <small>(~ <%= @filtered_count %> records)</small></h4>
<div class="card-description">
<div class="row">
<div class="col-md-12">
<input id="kaffy-search-field" type="text" name="search" value="<%= Map.get(@params, "search", "") %>" class="form-control bg-transparent" placeholder="Search <%= Kaffy.ResourceAdmin.plural_name(@my_resource) %>...">
</div>
</div>
</div>
<%= render KaffyWeb.ResourceView, "_table.html", conn: @conn, fields: @fields, context: @context, resource: @resource, my_resource: @my_resource, entries: @entries, params: @params %>
</div>
<div class="btn-group" role="group" aria-label="Basic example">
<% params = Map.to_list(@params) |> Enum.map(fn {k, v} -> {String.to_existing_atom(k), v} end) %>
<%= link "Previous", to: Kaffy.Utils.router().kaffy_resource_path(@conn, :index, @context, @resource, Keyword.merge(params, page: @page - 1)), class: "btn btn-outline-primary#{@prev_class}" %>
<%= link "Next", to: Kaffy.Utils.router().kaffy_resource_path(@conn, :index, @context, @resource, Keyword.merge(params, page: @page + 1)), class: "btn btn-outline-primary#{@next_class}" %>
</div>
</div>
</div>
<form id="kaffy-filters-form" method="get" style="display:none;">
<%= if Kaffy.ResourceSchema.has_field_filters?(@my_resource) do %>
<%= for field <- @fields do %>
<% {field, filters} = Kaffy.ResourceSchema.kaffy_field_filters(@my_resource[:schema], field) %>
<%= if filters do %>
<input id="custom-filter-<%= field %>" type="hidden" name="<%= field %>" value="<%= Map.get(@params, to_string(field), "") %>" />
<% end %>
<% end %>
<% end %>
<input id="kaffy-filter-search" type="hidden" name="search" value="<%= Map.get(@params, "search", "") %>" />
<input id="kaffy-filter-page" type="hidden" name="page" value="<%= Map.get(@params, "page", "1") %>" />
<input id="kaffy-filter-limit" type="hidden" name="limit" value="<%= Map.get(@params, "limit", "100") %>" />
<input id="kaffy-c" type="hidden" name="c" value="<%= @conn.params["c"] %>" />
<input id="kaffy-r" type="hidden" name="r" value="<%= @conn.params["r"] %>" />
<input id="kaffy-pick" type="hidden" name="pick" value="<%= @conn.params["pick"] %>" />
</form>

View file

@ -0,0 +1,76 @@
<%= for error <- Kaffy.ResourceError.display_errors(@conn, @changeset) do %>
<div><%= error %></div>
<% end %>
<div class="mt-3 grid-margin stretch-card">
<div class="card shadow <%= Kaffy.ResourceError.form_error_border_class(@changeset, "border-left-success")%>">
<div class="card-header">
<div class="row justify-content-between">
<div class="col-auto mr-auto">
<h1><%= @resource_name %> #<%= @changeset.data.id %></h1>
</div>
<div class="col-auto">
<%= if Kaffy.ResourceAdmin.resource_actions(@my_resource, @conn) do %>
<div class="float-right">
<div class="btn-group">
<button type="button" class="btn dropdown-toggle btn-sm btn-secondary" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fas fa-wrench"></i> <span class="d-none d-md-inline">Actions</span>
</button>
<div class="dropdown-menu">
<%= for {action_key, options} <- Kaffy.ResourceAdmin.resource_actions(@my_resource, @conn) do %>
<%= form_tag(Kaffy.Utils.router().kaffy_resource_path(@conn, :single_action, @context, @resource, @changeset.data.id, to_string(action_key)), method: :post) %>
<input type="submit" name="submit" value="<%= options.name %>" class="dropdown-item" />
</form>
<% end %>
</div>
</div>
</div>
<% end %>
</div>
</div>
</div>
<div class="card-body">
<%= f = form_for(@changeset, Kaffy.Utils.router().kaffy_resource_path(@conn, :update, @context, @resource, @changeset.data), method: :put, multipart: true) %>
<%= for {field, options} <- Kaffy.ResourceAdmin.form_fields(@my_resource) do %>
<%= if options.update != :hidden do %>
<%= Kaffy.ResourceForm.kaffy_input @conn, @changeset, f, field, options %>
<% end %>
<% end %>
<div class="p-2">
<%= link "Back", to: Kaffy.Utils.router().kaffy_resource_path(@conn, :index, @context, @resource), class: "btn btn-sm btn-light" %>
<input type="submit" name="submit" value="Save" class="btn btn-sm btn-primary" />
<input type="submit" name="submit" value="Save and add another" class="btn btn-sm btn-primary"/>
<input type="submit" name="submit" value="Save and continue editing" class="btn btn-sm btn-primary" />
<button type="button" class="btn btn-sm btn-danger float-right" data-toggle="modal" data-target="#delete-modal">
<i class="fas fa-trash"></i> Delete
</button>
</div>
</form>
</div>
</div>
<!-- Modal -->
<%= form_for(@changeset, Kaffy.Utils.router().kaffy_resource_path(@conn, :delete, @context, @resource, @changeset.data.id), method: :delete) %>
<div class="modal fade" id="delete-modal" tabindex="-1" role="dialog" aria-labelledby="delete-modal-label" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="delete-modal-label">Are you sure?</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
You might lose this record and other related records forever if you continue. Make sure this is what you really want to do.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger">Yes, delete this <%= @resource_name %></button>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,27 @@
<h1 class="mt-4">Tasks</h1>
<div class="row mt-3 col-md-12">
<table class="table table-striped">
<tr>
<th>Name</th>
<th>Runs Every (seconds)</th>
<th>Successful Runs</th>
<th>Failed Runs</th>
<th>Started (UTC)</th>
<th>Last Successful Run (UTC)</th>
<th>Last Failed Run (UTC)</th>
</tr>
<%= for task <- Kaffy.Tasks.tasks_info() do %>
<tr>
<td><%= task.name %></td>
<td><%= task.every %></td>
<td><%= task.successful %></td>
<td><%= task.failure %></td>
<td><%= task.started_at && DateTime.truncate(task.started_at, :second) %></td>
<td><%= task.last_successful && DateTime.truncate(task.last_successful, :second) %></td>
<td><%= task.last_failure && DateTime.truncate(task.last_failure, :second) %></td>
</tr>
<% end %>
</table>
</div>

View file

@ -0,0 +1,10 @@
defmodule KaffyWeb.HomeView do
@moduledoc false
use Phoenix.View,
root: "lib/kaffy_web/templates",
namespace: KaffyWeb
# import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1]
use Phoenix.HTML
end

View file

@ -0,0 +1,10 @@
defmodule KaffyWeb.LayoutView do
@moduledoc false
use Phoenix.View,
root: "lib/kaffy_web/templates",
namespace: KaffyWeb
import Phoenix.Controller, only: [get_flash: 2]
use Phoenix.HTML
end

View file

@ -0,0 +1,10 @@
defmodule KaffyWeb.ResourceView do
@moduledoc false
use Phoenix.View,
root: "lib/kaffy_web/templates",
namespace: KaffyWeb
# import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1]
use Phoenix.HTML
end

View file

@ -0,0 +1,10 @@
defmodule KaffyWeb.TaskView do
@moduledoc false
use Phoenix.View,
root: "lib/kaffy_web/templates",
namespace: KaffyWeb
# import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1]
use Phoenix.HTML
end

View file

@ -0,0 +1,7 @@
# defmodule Mix.Tasks.Kaffy.Detect do
# use Mix.Task
# @shortdoc "Auto-detect schemas and admin modules and display the resources list for Kaffy."
# def run(_) do
# end
# end

67
apps/admin/kaffy/mix.exs Normal file
View file

@ -0,0 +1,67 @@
defmodule Kaffy.MixProject do
use Mix.Project
@version "0.9.1"
def project do
[
app: :kaffy,
version: @version,
elixir: "~> 1.7",
compilers: [:phoenix] ++ Mix.compilers(),
start_permanent: Mix.env() == :prod,
description: description(),
package: package(),
name: "Kaffy",
source_url: "https://github.com/aesmail/kaffy",
deps: deps(),
docs: docs()
]
end
# Run "mix help compile.app" to learn about applications.
def application do
[
mod: {Kaffy.Application, []},
extra_applications: [:logger]
]
end
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:phoenix, "~> 1.4"},
{:phoenix_html, "~> 2.11"},
{:ecto, "~> 3.0"},
{:ex_doc, ">= 0.0.0", only: :dev, runtime: false}
]
end
defp description() do
"Powerfully simple admin package for phoenix applications"
end
defp package() do
[
maintainers: ["Abdullah Esmail"],
licenses: ["MIT"],
links: %{
"GitHub" => "https://github.com/aesmail/kaffy",
"Demo" => "https://kaffy.gigalixirapp.com/admin/"
}
]
end
def docs() do
[
main: "readme",
name: "Kaffy",
source_ref: "v#{@version}",
canonical: "http://hexdocs.pm/kaffy",
source_url: "https://github.com/aesmail/kaffy",
extras: [
"README.md"
]
]
end
end

21
apps/admin/kaffy/mix.lock Normal file
View file

@ -0,0 +1,21 @@
%{
"cachex": {:hex, :cachex, "3.2.0", "a596476c781b0646e6cb5cd9751af2e2974c3e0d5498a8cab71807618b74fe2f", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "aef93694067a43697ae0531727e097754a9e992a1e7946296f5969d6dd9ac986"},
"decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"},
"earmark": {:hex, :earmark, "1.4.4", "4821b8d05cda507189d51f2caeef370cf1e18ca5d7dfb7d31e9cafe6688106a4", [:mix], [], "hexpm", "1f93aba7340574847c0f609da787f0d79efcab51b044bb6e242cae5aca9d264d"},
"ecto": {:hex, :ecto, "3.4.3", "3a14c2500c3964165245a4f24a463e080762f7ccd0c632c763ea589f75ca205f", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b6f18dea95f2004d0369f6a8346513ca3f706614f4ede219a5f3fe5db5dd962"},
"eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm", "b14f1dc204321429479c569cfbe8fb287541184ed040956c8862cb7a677b8406"},
"ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"},
"jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"},
"makeup": {:hex, :makeup, "1.0.1", "82f332e461dc6c79dbd82fbe2a9c10d48ed07146f0a478286e590c83c52010b5", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "49736fe5b66a08d8575bf5321d716bac5da20c8e6b97714fec2bcd6febcfa1f8"},
"makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"},
"mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"},
"nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"},
"phoenix": {:hex, :phoenix, "1.4.17", "1b1bd4cff7cfc87c94deaa7d60dd8c22e04368ab95499483c50640ef3bd838d8", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a8e5d7a3d76d452bb5fb86e8b7bd115f737e4f8efe202a463d4aeb4a5809611"},
"phoenix_html": {:hex, :phoenix_html, "2.14.2", "b8a3899a72050f3f48a36430da507dd99caf0ac2d06c77529b1646964f3d563e", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "58061c8dfd25da5df1ea0ca47c972f161beb6c875cd293917045b92ffe1bf617"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm", "1f13f9f0f3e769a667a6b6828d29dec37497a082d195cc52dbef401a9b69bf38"},
"plug": {:hex, :plug, "1.10.0", "6508295cbeb4c654860845fb95260737e4a8838d34d115ad76cd487584e2fc4d", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "422a9727e667be1bf5ab1de03be6fa0ad67b775b2d84ed908f3264415ef29d4a"},
"plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"},
"sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"},
"telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"},
"unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm", "6c7729a2d214806450d29766abc2afaa7a2cbecf415be64f36a6691afebb50e5"},
}

View file

@ -0,0 +1,12 @@
{
"name": "kaffy",
"description": "Powerfully simple admin package for phoenix applications",
"dependencies": {
"bootstrap": "^4.5.0",
"compass-mixins": "^0.12.10"
},
"author": "aesmail",
"url": "https://github.com/aesmail/kaffy/",
"copyright": "Copyright (c) 2020 Abdullah Esmail",
"license": "MIT License"
}

View file

@ -0,0 +1,229 @@
/*!
* custom Kaffy Css
*/
.navbar .navbar-brand-wrapper .navbar-brand.brand-logo-mini img {
width: calc(4.375rem - 40px);
max-width: 100%;
height: 28px;
margin: auto;
}
.sidebar .nav.sub-menu .nav-item .nav-link::before {
content: "\f061";
font-family: "Font Awesome 5 Free";
display: block;
position: absolute;
left: 0px;
top: 50%;
-webkit-transform: translateY(-50%);
transform: translateY(-50%);
color: #d8dff5;
font-size: .75rem;
font-weight:900;
}
.sidebar .nav .nav-item .nav-link .menu-arrow{
margin-right: 0.4rem;
}
.sidebar .nav .nav-item .nav-link[aria-expanded="true"] .menu-arrow:before {
content: "\f078";
color: #c0cbee;
}
.sidebar .nav .nav-item.active .nav-link[aria-expanded="true"] .menu-arrow:before {
color: #4285f4;
}
.sidebar .nav .nav-item .nav-link i.menu-arrow::before {
content: "\f078";
font-family: "Font Awesome 5 Free";
line-height: 1;
display: block;
color: #4285f4;
font-size: .75rem;
font-weight:900;
}
.sidebar .nav .nav-item .nav-link.collapsed i.menu-arrow::before {
content: "\f053";
font-family: "Font Awesome 5 Free";
line-height: 1;
display: block;
color: #c0cbee;
font-size: 1rem;
font-weight: 900;
}
.sidebar .nav .nav-item.active .nav-link.collapsed .menu-arrow:before {
color: #4285f4;
}
.checkbox-selected-count {
margin-left: 0.5rem;
}
.sidebar .nav .nav-item:hover {
background: #1240c4;
}
.btn-sm, .btn-group-sm > .btn {
padding: 0.5rem 0.81rem;
margin: 0.2rem;
}
.table .thead-dark th {
color: #fff;
background-color: #4e73df;
border-color: #4f64a1;
}
.table-striped tbody tr:nth-of-type(2n+1) {
background-color: rgba(0,0,0,.02);
}
.index-table-footer {
margin-top: 20px;
}
.btn-outline-primary:hover, .btn-outline-primary:focus, .btn-outline-primary:active {
background: -webkit-gradient(linear, left top, right top, from(#559bd8), to(#559bd8));
background: linear-gradient(to right, #4e73df, #4e73df);
color: #b4c2ec;
}
/* flatpickr */
.flatpickr-input[readonly] {
cursor: pointer;
background-color: #ffffff;
}
/* Would need to overwrite our purple admin template with the bellow */
/* improve style of the datetime picker */
/* Should be updated in _form.scss */
.input-group-append .input-group-text, .input-group-prepend .input-group-text {
border-color: #ced4da;
padding: 0.94rem 0.90rem;
color: #c9c8c8;
border-radius: 0.2125rem;
}
.input-group-prepend {
margin-right: 0px;
}
.input-group-text {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: .375rem .75rem;
margin-bottom: 0;
font-size: 1rem;
font-weight: 400;
line-height: 0.5;
color: #495057;
text-align: center;
white-space: nowrap;
background-color: #e9ecef;
border: 1px solid #ced4da;
border-radius: .25rem;
border-top-right-radius: 0.25rem;
border-bottom-right-radius: 0.25rem;
}
/* Make the custom select height bigger */
/* Should be updated in _custom_form.scss */
.custom-select {
height: calc(2em + 0.75rem + 2px);
}
a.kaffy-order-field {
cursor:pointer;
}
/* Improved Alert box */
.alert{padding: 20px; transition:all .3s ease;}
.alert:hover, .alert:focus{transform:scale(1.04); -webkit-box-shadow: 0 8px 20px #e8e8e8;box-shadow: 0 8px 20px #e8e8e8;}
.alert .close{opacity:0; transition:opacity .3s ease;}
.alert:hover .close, .alert:focus .close{opacity:.2;}
.alert i{min-width:30px; text-align:center;}
.alert-primary{
color: #FFFFFF;
background: #4285F4;
border-color: #387ae4;
}
.alert-secondary{
color: #5a6ca1;
background: #b6c6f7;
border-color: #97a8da;
}
.alert-success{
color: #FFFFFF;
background: #00C851;
border-color: #02af47;
}
.alert-info{
color: #FFFFFF;
background: #5ABBDB;
border-color: #4ba5c4;
}
.alert-warning{
color: #FFFFFF;
background: #ffbb33;
border-color: #e9a826;
}
.alert-danger{
color: #FFFFFF;
background: #fa5e5e;
border-color: #db4646;
}
@media (max-width: 991px) {
.content-wrapper {
padding: 1rem 0rem 1rem 0rem;
width: 100%;
}
}
/* Fix mobile large margin */
@media (max-width: 575.98px) {
.col-lg-12 {
padding-right: 0.15rem;
padding-left: 0.15rem;
}
.card .card-body {
padding: 0.25rem 0.25rem;
}
.table th, .table td {
padding: 0.375rem;
}
}
/* center pagination */
.index-pagination {
margin:0 auto;
}
/* Form Help text */
.help_text {
margin-top: 0.25rem;
color: #9c9c9c;
font-size: .75rem;
font-style: italic;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 153 187" style="enable-background:new 0 0 153 187;" xml:space="preserve">
<style type="text/css">
.st0{opacity:0.2;fill:#FFFFFF;}
</style>
<g>
<title>3</title>
<desc>Created with Sketch.</desc>
<g id="Mask-_x2B_-Mask-Mask" transform="translate(14.000000, 0.000000)">
<g id="Mask">
</g>
<g id="Mask_1_">
<path class="st0" d="M138,141.2c-3.6,0.5-7.3,0.8-11,0.8c-29.6,0-55.4-16.5-68.6-40.9c-6-11-9.4-23.7-9.4-37.1
c0-26.5,13.2-49.9,33.4-64H138v129.5"/>
</g>
<g id="Mask_2_">
<path class="st0" d="M138,141.2V187H-15c0.2-43.3,31.9-79.1,73.4-85.9c4.6-0.8,9.3-1.1,14.1-1.1c26.1,0,49.5,11.4,65.5,29.5"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 920 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Some files were not shown because too many files have changed in this diff Show more