feat: Basic post design
|
@ -1 +0,0 @@
|
|||
Subproject commit 0dd35bbe6ff20366ab4ddab0069bacf53373002a
|
4
apps/admin/kaffy/.formatter.exs
Normal file
|
@ -0,0 +1,4 @@
|
|||
# Used by "mix format"
|
||||
[
|
||||
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
|
||||
]
|
22
apps/admin/kaffy/.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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.
|
17
apps/admin/kaffy/.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
|
@ -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
|
266
apps/admin/kaffy/CHANGELOG.md
Normal 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
|
@ -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
|
@ -0,0 +1,976 @@
|
|||
<img src="https://opencollective.com/kaffy/tiers/sponsor/badge.svg?label=sponsor&color=brightgreen" />
|
||||
|
||||

|
||||
|
||||
## 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`.
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
`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/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.
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
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.
|
1
apps/admin/kaffy/_config.yml
Normal file
|
@ -0,0 +1 @@
|
|||
theme: jekyll-theme-cayman
|
4
apps/admin/kaffy/config/config.exs
Normal file
|
@ -0,0 +1,4 @@
|
|||
use Mix.Config
|
||||
|
||||
# Use Jason for JSON parsing in Phoenix
|
||||
# config :phoenix, :json_library, Jason
|
BIN
apps/admin/kaffy/demos/kaffy_custom_links.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
apps/admin/kaffy/demos/kaffy_custom_pages.png
Normal file
After Width: | Height: | Size: 136 KiB |
BIN
apps/admin/kaffy/demos/kaffy_dashboard.png
Normal file
After Width: | Height: | Size: 112 KiB |
BIN
apps/admin/kaffy/demos/kaffy_filters.png
Normal file
After Width: | Height: | Size: 85 KiB |
BIN
apps/admin/kaffy/demos/kaffy_form.png
Normal file
After Width: | Height: | Size: 50 KiB |
BIN
apps/admin/kaffy/demos/kaffy_index.png
Normal file
After Width: | Height: | Size: 96 KiB |
BIN
apps/admin/kaffy/demos/kaffy_list_actions.png
Normal file
After Width: | Height: | Size: 58 KiB |
BIN
apps/admin/kaffy/demos/kaffy_multistep_actions.png
Normal file
After Width: | Height: | Size: 38 KiB |
BIN
apps/admin/kaffy/demos/kaffy_resource_actions.png
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
apps/admin/kaffy/demos/kaffy_tasks.png
Normal file
After Width: | Height: | Size: 35 KiB |
7
apps/admin/kaffy/lib/kaffy.ex
Normal file
|
@ -0,0 +1,7 @@
|
|||
defmodule Kaffy do
|
||||
@moduledoc false
|
||||
|
||||
def hello do
|
||||
:world
|
||||
end
|
||||
end
|
15
apps/admin/kaffy/lib/kaffy/application.ex
Normal 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
|
33
apps/admin/kaffy/lib/kaffy/cache/client.ex
vendored
Normal 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
|
72
apps/admin/kaffy/lib/kaffy/cache/table.ex
vendored
Normal 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
|
32
apps/admin/kaffy/lib/kaffy/pagination.ex
Normal 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
|
405
apps/admin/kaffy/lib/kaffy/resource_admin.ex
Normal 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
|
206
apps/admin/kaffy/lib/kaffy/resource_callbacks.ex
Normal 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
|
54
apps/admin/kaffy/lib/kaffy/resource_error.ex
Normal 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
|
362
apps/admin/kaffy/lib/kaffy/resource_form.ex
Normal 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
|
39
apps/admin/kaffy/lib/kaffy/resource_params.ex
Normal 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
|
153
apps/admin/kaffy/lib/kaffy/resource_query.ex
Normal 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
|
315
apps/admin/kaffy/lib/kaffy/resource_schema.ex
Normal 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
|
57
apps/admin/kaffy/lib/kaffy/routes.ex
Normal 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
|
19
apps/admin/kaffy/lib/kaffy/scheduler/supervisor.ex
Normal 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
|
56
apps/admin/kaffy/lib/kaffy/scheduler/task.ex
Normal 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
|
19
apps/admin/kaffy/lib/kaffy/tasks.ex
Normal 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
|
405
apps/admin/kaffy/lib/kaffy/utils.ex
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
10
apps/admin/kaffy/lib/kaffy_web/templates/home/_card.html.eex
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
10
apps/admin/kaffy/lib/kaffy_web/templates/home/_text.html.eex
Normal 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>
|
|
@ -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>
|
47
apps/admin/kaffy/lib/kaffy_web/templates/home/index.html.eex
Normal 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 %>
|
170
apps/admin/kaffy/lib/kaffy_web/templates/layout/app.html.eex
Normal 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>
|
181
apps/admin/kaffy/lib/kaffy_web/templates/layout/backup.html.eex
Normal 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>
|
|
@ -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>
|
0
apps/admin/kaffy/lib/kaffy_web/templates/page
Normal 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>
|
|
@ -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>
|
|
@ -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 %>
|
127
apps/admin/kaffy/lib/kaffy_web/templates/resource/index.html.eex
Normal 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">×</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 %>
|
|
@ -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" %>
|
||||
<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>
|
|
@ -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>
|
|
@ -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">×</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>
|
27
apps/admin/kaffy/lib/kaffy_web/templates/task/index.html.eex
Normal 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>
|
10
apps/admin/kaffy/lib/kaffy_web/views/home_view.ex
Normal 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
|
10
apps/admin/kaffy/lib/kaffy_web/views/layout_view.ex
Normal 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
|
10
apps/admin/kaffy/lib/kaffy_web/views/resource_view.ex
Normal 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
|
10
apps/admin/kaffy/lib/kaffy_web/views/task_view.ex
Normal 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
|
7
apps/admin/kaffy/lib/mix/tasks/detect.ex
Normal 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
|
@ -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
|
@ -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"},
|
||||
}
|
12
apps/admin/kaffy/package.json
Normal 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"
|
||||
}
|
229
apps/admin/kaffy/priv/static/assets/css/kaffy.css
Normal 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;
|
||||
}
|
BIN
apps/admin/kaffy/priv/static/assets/fonts/Ubuntu/Ubuntu-Bold.eot
Normal file
BIN
apps/admin/kaffy/priv/static/assets/fonts/Ubuntu/Ubuntu-Bold.ttf
Normal file
BIN
apps/admin/kaffy/priv/static/assets/images/dashboard/circle.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
|
@ -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 |
BIN
apps/admin/kaffy/priv/static/assets/images/dashboard/img_1.jpg
Normal file
After Width: | Height: | Size: 21 KiB |
BIN
apps/admin/kaffy/priv/static/assets/images/dashboard/img_2.jpg
Normal file
After Width: | Height: | Size: 21 KiB |
BIN
apps/admin/kaffy/priv/static/assets/images/dashboard/img_3.jpg
Normal file
After Width: | Height: | Size: 33 KiB |
BIN
apps/admin/kaffy/priv/static/assets/images/dashboard/img_4.jpg
Normal file
After Width: | Height: | Size: 24 KiB |
After Width: | Height: | Size: 2.2 KiB |
After Width: | Height: | Size: 2.4 KiB |
After Width: | Height: | Size: 2.3 KiB |
After Width: | Height: | Size: 2.1 KiB |
BIN
apps/admin/kaffy/priv/static/assets/images/faces/face1.jpg
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
apps/admin/kaffy/priv/static/assets/images/faces/face10.jpg
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
apps/admin/kaffy/priv/static/assets/images/faces/face11.jpg
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
apps/admin/kaffy/priv/static/assets/images/faces/face12.jpg
Normal file
After Width: | Height: | Size: 9.8 KiB |
BIN
apps/admin/kaffy/priv/static/assets/images/faces/face13.jpg
Normal file
After Width: | Height: | Size: 14 KiB |