├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ └── ci.yml ├── .dockerignore ├── lib ├── live_admin │ ├── gettext.ex │ ├── session │ │ ├── store.ex │ │ └── agent.ex │ ├── components │ │ ├── home.ex │ │ ├── layout │ │ │ ├── app.html.heex │ │ │ └── layout.html.heex │ │ ├── home │ │ │ └── content.ex │ │ ├── session.ex │ │ ├── resource │ │ │ ├── form │ │ │ │ ├── array_input.ex │ │ │ │ ├── map_input.ex │ │ │ │ └── search_select.ex │ │ │ ├── view.ex │ │ │ ├── form.ex │ │ │ └── index.ex │ │ ├── nav.ex │ │ ├── session │ │ │ └── content.ex │ │ └── container.ex │ ├── session.ex │ ├── error_helpers.ex │ ├── view.ex │ ├── components.ex │ ├── router.ex │ └── resource.ex ├── application.ex └── live_admin.ex ├── assets ├── postcss.config.js ├── tailwind.config.js ├── package.json ├── esbuild.js ├── js │ └── app.js └── css │ └── app.css ├── .formatter.exs ├── Dockerfile ├── dev ├── readme_generator.ex ├── gettext │ └── tr │ │ └── LC_MESSAGES │ │ └── default.po └── initdb │ └── structure.sql ├── config └── config.exs ├── docker-compose.yml ├── .gitignore ├── test ├── live_admin_test.exs ├── test_helper.exs └── live_admin │ └── components │ └── container_test.exs ├── LICENSE.md ├── dist └── css │ ├── default_overrides.css │ └── app.css ├── mix.exs ├── README.md ├── README.md.eex ├── mix.lock └── dev.exs /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [tfwright] 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | _build 2 | deps 3 | assets/node_modules 4 | test 5 | -------------------------------------------------------------------------------- /lib/live_admin/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.Gettext do 2 | use Gettext, otp_app: :live_admin 3 | end 4 | -------------------------------------------------------------------------------- /assets/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | plugins: [Phoenix.LiveView.HTMLFormatter], 4 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{heex,ex,exs}"] 5 | ] 6 | -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | './css/**/*.css', 4 | '../lib/live_admin/**/*.*ex', 5 | ], 6 | theme: {}, 7 | variants: {}, 8 | plugins: [] 9 | }; 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM bitwalker/alpine-elixir-phoenix:1.13 2 | 3 | ADD mix.exs mix.lock ./ 4 | RUN mix do deps.get, deps.compile 5 | 6 | ADD assets assets/ 7 | RUN npm --prefix assets install 8 | RUN npm --prefix assets run build 9 | -------------------------------------------------------------------------------- /dev/readme_generator.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.READMECompiler do 2 | use Docout, output_path: "README.md" 3 | 4 | @impl true 5 | def format(_) do 6 | File.cwd!() 7 | |> Path.join("./README.md.eex") 8 | |> File.read!() 9 | |> EEx.eval_string(app_version: Application.spec(:live_admin)[:vsn]) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/live_admin/session/store.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.Session.Store do 2 | @type session :: LiveAdmin.Session.t() 3 | @type conn :: Plug.Conn.t() 4 | @type id :: String.t() 5 | @type live_session :: map() 6 | 7 | @callback init!(conn) :: id 8 | @callback load!(id) :: session 9 | @callback persist!(session) :: :ok 10 | end 11 | -------------------------------------------------------------------------------- /lib/application.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.Application do 2 | use Application 3 | 4 | def start(_type, _args) do 5 | opts = [strategy: :one_for_one, name: LiveAdmin.Supervisor] 6 | Supervisor.start_link(children(), opts) 7 | end 8 | 9 | defp children do 10 | [ 11 | {LiveAdmin.Session.Agent, %{}}, 12 | {Task.Supervisor, name: LiveAdmin.Task.Supervisor} 13 | ] 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/live_admin/components/home.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.Components.Home do 2 | use Phoenix.LiveView 3 | 4 | @impl true 5 | def mount(_params, %{"components" => %{home: mod}, "title" => title}, socket) do 6 | {:ok, 7 | assign(socket, 8 | mod: mod, 9 | title: title 10 | )} 11 | end 12 | 13 | @impl true 14 | def render(assigns) do 15 | ~H""" 16 | <.live_component module={@mod} id="content" /> 17 | """ 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :phoenix, :json_library, Jason 4 | config :phoenix, :stacktrace_depth, 20 5 | 6 | config :logger, level: :warn 7 | config :logger, :console, format: "[$level] $message\n" 8 | 9 | config :phoenix, LiveAdmin.Endpoint, 10 | watchers: [ 11 | node: ["esbuild.js", "--watch", cd: Path.expand("../assets", __DIR__)] 12 | ] 13 | 14 | config :docout, 15 | app_name: :live_admin, 16 | formatters: [LiveAdmin.READMECompiler] 17 | -------------------------------------------------------------------------------- /lib/live_admin/session.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.Session do 2 | use Ecto.Schema 3 | 4 | @type t() :: %__MODULE__{ 5 | id: String.t(), 6 | prefix: String.t(), 7 | locale: String.t(), 8 | metadata: map() 9 | } 10 | 11 | @primary_key {:id, :string, autogenerate: false} 12 | embedded_schema do 13 | field(:prefix, :string) 14 | field(:locale, :string, default: "en") 15 | field(:metadata, :map, default: %{}) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/live_admin/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(form, field) do 12 | Enum.map(Keyword.get_values(form.errors, field), fn {desc, _details} -> 13 | content_tag(:span, desc, 14 | class: "resource__error", 15 | phx_feedback_for: input_id(form, field) 16 | ) 17 | end) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/live_admin/components/layout/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
<%= Phoenix.Flash.get(@flash, :info) %>
3 |
4 | <.live_component 5 | id="nav" 6 | module={@nav_mod} 7 | title={@title} 8 | base_path={@base_path} 9 | resources={@resources} 10 | resource={assigns[:resource]} 11 | prefix={assigns[:prefix]} 12 | key={assigns[:key]} 13 | /> 14 |
15 | <%= @inner_content %> 16 |
17 |
18 |
19 | -------------------------------------------------------------------------------- /lib/live_admin/components/home/content.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.Components.Home.Content do 2 | use Phoenix.LiveComponent 3 | 4 | @impl true 5 | def render(assigns) do 6 | ~H""" 7 |
8 |
9 | This is the default LiveAdmin home page content. 10 | 11 | To use your own component, set the value of :home 12 | in the components option to a module that uses LiveComponent. LiveAdmin will render that component instead of this one in your app. 13 |
14 |
15 | """ 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/live_admin/components/session.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.Components.Session do 2 | use Phoenix.LiveView 3 | 4 | alias Ecto.Changeset 5 | 6 | @impl true 7 | def mount(_params, %{"components" => %{session: mod}}, socket) do 8 | if socket.assigns.session do 9 | {:ok, assign(socket, changeset: Changeset.change(socket.assigns.session), mod: mod)} 10 | else 11 | {:ok, socket} 12 | end 13 | end 14 | 15 | @impl true 16 | def render(assigns = %{changeset: _, mod: _}) do 17 | ~H""" 18 | <.live_component module={@mod} id="content" changeset={@changeset} /> 19 | """ 20 | end 21 | 22 | def render(assigns), do: ~H" 23 | " 24 | end 25 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "live_admin_assets", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "node esbuild.js", 7 | "release": "node esbuild.js --release", 8 | "watch": "node esbuild.js --watch" 9 | }, 10 | "dependencies": { 11 | "clipboard": "^2.0.10", 12 | "phoenix": "file:../deps/phoenix", 13 | "phoenix_html": "file:../deps/phoenix_html", 14 | "phoenix_live_view": "file:../deps/phoenix_live_view", 15 | "toastify-js": "^1.11.2", 16 | "topbar": "^1.0.1" 17 | }, 18 | "devDependencies": { 19 | "autoprefixer": "^10.4.1", 20 | "esbuild": "^0.14.0", 21 | "postcss": "^8.4.5", 22 | "tailwindcss": "^3.0.11" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | web: 5 | build: '.' 6 | ports: 7 | - "4000:4000" 8 | volumes: 9 | - .:/opt/app 10 | environment: 11 | PG_URL: postgres:postgres@db 12 | LIVE_ADMIN_DEV: 'true' 13 | depends_on: 14 | - db 15 | command: mix dev 16 | healthcheck: 17 | test: curl --fail http://localhost:4000 || exit 1 18 | interval: 30s 19 | timeout: 10s 20 | retries: 5 21 | db: 22 | environment: 23 | POSTGRES_DB: phx_admin_dev 24 | POSTGRES_PASSWORD: postgres 25 | POSTGRES_USER: postgres 26 | POSTGRES_HOST_AUTH_METHOD: trust 27 | image: 'postgres:13-alpine' 28 | volumes: 29 | - './dev/initdb:/docker-entrypoint-initdb.d' 30 | -------------------------------------------------------------------------------- /lib/live_admin/components/layout/layout.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | <%= @title %> 11 | 14 | <%= csrf_meta_tag() %> 15 | 16 | 17 | 18 | <%= @inner_content %> 19 | 20 | 23 | 24 | -------------------------------------------------------------------------------- /dev/gettext/tr/LC_MESSAGES/default.po: -------------------------------------------------------------------------------- 1 | msgid "Home" 2 | msgstr "Evim" 3 | 4 | msgid "New" 5 | msgstr "Yeni" 6 | 7 | msgid "Run task" 8 | msgstr "Programı çalıştır" 9 | 10 | msgid "Set prefix" 11 | msgstr "Önek seç" 12 | 13 | msgid "Prev" 14 | msgstr "Önceki" 15 | 16 | msgid "Next" 17 | msgstr "Sonraki" 18 | 19 | msgid "Name" 20 | msgstr "Isim" 21 | 22 | msgid "Email" 23 | msgstr "E-posta" 24 | 25 | msgid "Active" 26 | msgstr "Işleyen" 27 | 28 | msgid "Birth date" 29 | msgstr "Doğum tarihi" 30 | 31 | msgid "Stars count" 32 | msgstr "Yıldızlar" 33 | 34 | msgid "Encrypted password" 35 | msgstr "Şifre" 36 | 37 | msgid "Status" 38 | msgstr "Durum" 39 | 40 | msgid "Yes" 41 | msgstr "Evet" 42 | 43 | msgid "No" 44 | msgstr "Yok" 45 | 46 | msgid "Inserted at" 47 | msgstr "Kaydedilen zaman" 48 | 49 | 50 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a problem you encountered trying to use the app as documented 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Environment:** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /lib/live_admin/session/agent.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.Session.Agent do 2 | use Agent 3 | 4 | @behaviour LiveAdmin.Session.Store 5 | 6 | def start_link(%{} = initial_state) do 7 | Agent.start_link(fn -> initial_state end, name: __MODULE__) 8 | end 9 | 10 | @impl LiveAdmin.Session.Store 11 | def init!(conn) do 12 | id = Map.get(conn.assigns, :user_id, Ecto.UUID.generate()) 13 | 14 | Agent.update(__MODULE__, fn state -> 15 | Map.put_new(state, id, %LiveAdmin.Session{id: id}) 16 | end) 17 | 18 | id 19 | end 20 | 21 | @impl LiveAdmin.Session.Store 22 | def load!(id) do 23 | Agent.get(__MODULE__, &Map.get(&1, id)) 24 | end 25 | 26 | @impl LiveAdmin.Session.Store 27 | def persist!(session) do 28 | Agent.update(__MODULE__, fn sessions -> 29 | Map.put(sessions, session.id, session) 30 | end) 31 | 32 | :ok 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | live_admin-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | # Compiled assets 29 | /priv/ 30 | 31 | # Node artifacts 32 | /.npm/ 33 | /assets/node_modules/ 34 | 35 | .bash_history 36 | 37 | .DS_Store 38 | -------------------------------------------------------------------------------- /test/live_admin_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveAdminTest do 2 | use ExUnit.Case, async: true 3 | 4 | setup do 5 | Ecto.Adapters.SQL.Sandbox.checkout(LiveAdminTest.Repo) 6 | end 7 | 8 | describe "associated_resource/3 when association schema is not a configured resource" do 9 | test "returns nil" do 10 | assert is_nil(LiveAdmin.associated_resource(LiveAdminTest.User, :some_id, [])) 11 | end 12 | end 13 | 14 | describe "associated_resource/3 when association schema is a configured resource" do 15 | test "returns the association schema" do 16 | assert {nil, LiveAdminTest.User} = 17 | LiveAdmin.associated_resource(LiveAdminTest.Post, :user_id, [ 18 | {nil, LiveAdminTest.User} 19 | ]) 20 | end 21 | end 22 | 23 | describe "record_label/2 when config uses mfa" do 24 | assert 1 = LiveAdmin.record_label(%{id: 1}, LiveAdminTest.PostResource) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /assets/esbuild.js: -------------------------------------------------------------------------------- 1 | const esbuild = require('esbuild') 2 | 3 | // Decide which mode to proceed with 4 | let mode = 'build' 5 | process.argv.slice(2).forEach((arg) => { 6 | if (arg === '--watch') { 7 | mode = 'watch' 8 | } else if (arg === '--release') { 9 | mode = 'release' 10 | } 11 | }) 12 | 13 | // Define esbuild options + extras for watch and deploy 14 | let opts = { 15 | entryPoints: ['js/app.js'], 16 | bundle: true, 17 | logLevel: 'info', 18 | target: 'es2016', 19 | outdir: '../dist/js', 20 | } 21 | if (mode === 'watch') { 22 | opts = { 23 | watch: true, 24 | sourcemap: 'inline', 25 | ...opts 26 | } 27 | } 28 | if (mode === 'release') { 29 | opts = { 30 | minify: true, 31 | ...opts 32 | } 33 | } 34 | 35 | // Start esbuild with previously defined options 36 | // Stop the watcher when STDIN gets closed (no zombies please!) 37 | esbuild.build(opts).then((result) => { 38 | if (mode === 'watch') { 39 | process.stdin.pipe(process.stdout) 40 | process.stdin.on('end', () => { result.stop() }) 41 | } 42 | }).catch((error) => { 43 | process.exit(1) 44 | }) 45 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: push 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | env: 10 | MIX_ENV: test 11 | 12 | strategy: 13 | matrix: 14 | elixir: ["1.13.x", "1.14.x", "1.15.x"] 15 | otp: ["24.x", "25.x"] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up Elixir 20 | uses: erlef/setup-beam@v1 21 | with: 22 | elixir-version: ${{ matrix.elixir }} 23 | otp-version: ${{ matrix.otp }} 24 | - uses: actions/cache@v2 25 | with: 26 | path: | 27 | deps 28 | _build 29 | key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-deps-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 30 | - name: Get deps 31 | run: docker-compose run web mix deps.get 32 | - name: Check for uncommitted changes 33 | run: exit $( git status --porcelain | head -255 | wc -l ) 34 | - name: Run tests 35 | run: docker-compose run -e MIX_ENV=test web mix do compile --warnings-as-errors, test 36 | - name: Check dev 37 | run: docker-compose up --wait 38 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright (c) 2022 Thomas F Wright 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /dist/css/default_overrides.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #FFF; 3 | } 4 | 5 | .nav { 6 | background-color: rgb(243 244 246); 7 | } 8 | 9 | .resource__action--btn { 10 | background-color: rgb(67, 56, 202); 11 | border-color: rgb(67, 56, 202); 12 | color: rgb(243 244 246); 13 | } 14 | 15 | .resource__action--secondary { 16 | background-color: rgb(198, 196, 223); 17 | border-color: rgb(198, 196, 223); 18 | } 19 | 20 | .resource__action--secondary:hover { 21 | background-color: rgb(155, 149, 222); 22 | border-color: rgb(155, 149, 222); 23 | } 24 | 25 | .resource__action--danger { 26 | background-color: rgb(250, 20, 58); 27 | border-color: rgb(250, 20, 58); 28 | color: rgb(243 244 246); 29 | } 30 | 31 | .resource__action--btn:hover { 32 | background-color: rgb(55 48 163); 33 | border-color: rgb(67, 56, 202); 34 | } 35 | 36 | .resource__header { 37 | background-color: rgb(224 231 255); 38 | } 39 | 40 | .resource__row--selected { 41 | background-color: rgb(224, 231, 255); 42 | } 43 | 44 | .nav a:hover { 45 | background-color: rgb(165 180 252); 46 | } 47 | 48 | .toast__container--error { 49 | border-color: rgb(239, 68, 68); 50 | color: rgb(239, 68, 68); 51 | } 52 | 53 | .toast__container--success { 54 | border-color: rgb(102, 153, 0); 55 | color: rgb(102, 153, 0); 56 | } 57 | 58 | .toast__container--info { 59 | border-color: rgb(67, 56, 202); 60 | color: rgb(67, 56, 202); 61 | } 62 | -------------------------------------------------------------------------------- /dev/initdb/structure.sql: -------------------------------------------------------------------------------- 1 | create table users ( 2 | id uuid, 3 | name varchar(100), 4 | email varchar(100), 5 | birth_date date, 6 | inserted_at timestamp without time zone, 7 | active boolean, 8 | stars_count integer, 9 | settings jsonb, 10 | private_data jsonb, 11 | encrypted_password text, 12 | status varchar(100), 13 | other_resource_id int, 14 | roles character varying[] DEFAULT '{}'::character varying[], 15 | rating real 16 | ); 17 | 18 | CREATE UNIQUE INDEX users_email_index ON users USING btree (email); 19 | 20 | create table user_profiles ( 21 | id serial, 22 | user_id uuid 23 | ); 24 | 25 | create table posts ( 26 | id serial, 27 | user_id uuid, 28 | disabled_user_id uuid, 29 | title text NOT NULL, 30 | body text, 31 | inserted_at timestamp without time zone, 32 | tags jsonb, 33 | categories jsonb, 34 | status varchar(100), 35 | previous_versions jsonb DEFAULT '[]'::jsonb, 36 | metadata jsonb DEFAULT '{}'::jsonb 37 | ); 38 | 39 | create schema alt; 40 | 41 | create table alt.users ( 42 | id uuid, 43 | name varchar(100), 44 | email varchar(100), 45 | birth_date date, 46 | inserted_at timestamp without time zone, 47 | active boolean, 48 | stars_count integer, 49 | settings jsonb, 50 | private_data jsonb, 51 | encrypted_password text, 52 | status varchar(100), 53 | other_resource_id int, 54 | roles character varying[] DEFAULT '{}'::character varying[], 55 | rating real 56 | ); 57 | 58 | CREATE UNIQUE INDEX users_email_index ON alt.users USING btree (email); 59 | 60 | create table alt.user_profiles ( 61 | id serial, 62 | user_id uuid 63 | ); 64 | 65 | create table alt.posts ( 66 | id serial, 67 | user_id uuid, 68 | disabled_user_id uuid, 69 | title text NOT NULL, 70 | body text, 71 | inserted_at timestamp without time zone, 72 | tags jsonb, 73 | categories jsonb, 74 | status varchar(100), 75 | previous_versions jsonb DEFAULT '[]'::jsonb, 76 | metadata jsonb DEFAULT '{}'::jsonb 77 | ); 78 | -------------------------------------------------------------------------------- /lib/live_admin/view.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.View do 2 | use Phoenix.Component 3 | use Phoenix.HTML 4 | 5 | js_path = Path.join(__DIR__, "../../dist/js/app.js") 6 | css_path = Path.join(__DIR__, "../../dist/css/app.css") 7 | default_css_overrides_path = Path.join(__DIR__, "../../dist/css/default_overrides.css") 8 | 9 | @external_resource js_path 10 | @external_resource css_path 11 | @external_resource default_css_overrides_path 12 | 13 | @app_js File.read!(js_path) 14 | @app_css File.read!(css_path) 15 | @default_css_overrides File.read!(default_css_overrides_path) 16 | 17 | @env Mix.env() 18 | 19 | @supported_primitive_types [ 20 | :string, 21 | :boolean, 22 | :date, 23 | :integer, 24 | :naive_datetime, 25 | :utc_datetime, 26 | :id, 27 | :binary_id, 28 | :float 29 | ] 30 | 31 | embed_templates("components/layout/*") 32 | 33 | def render("layout.html", assigns), do: layout(assigns) 34 | 35 | def render_js, do: "var ENV = \"#{@env}\";" <> @app_js 36 | 37 | def render_css(session) do 38 | Application.get_env(:live_admin, :css_overrides, @default_css_overrides) 39 | |> case do 40 | {m, f, a} -> @app_css <> apply(m, f, [session | a]) 41 | override_css -> @app_css <> override_css 42 | end 43 | end 44 | 45 | def sort_param_name(field), do: :"#{field}_sort" 46 | def drop_param_name(field), do: :"#{field}_drop" 47 | 48 | def field_class(type) when type in @supported_primitive_types, do: to_string(type) 49 | def field_class(:map), do: "map" 50 | def field_class({:array, _}), do: "array" 51 | def field_class({_, Ecto.Embedded, _}), do: "embed" 52 | def field_class({_, Ecto.Enum, _}), do: "enum" 53 | def field_class(_), do: "other" 54 | 55 | def supported_type?(type) when type in @supported_primitive_types, do: true 56 | def supported_type?(:map), do: true 57 | def supported_type?({:array, _}), do: true 58 | def supported_type?({_, Ecto.Embedded, _}), do: true 59 | def supported_type?({_, Ecto.Enum, _}), do: true 60 | def supported_type?(_), do: false 61 | end 62 | -------------------------------------------------------------------------------- /lib/live_admin/components/resource/form/array_input.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.Components.Container.Form.ArrayInput do 2 | use Phoenix.LiveComponent 3 | use Phoenix.HTML 4 | 5 | alias Phoenix.LiveView.JS 6 | 7 | @impl true 8 | def update(assigns = %{form: form, field: field}, socket) do 9 | socket = 10 | socket 11 | |> assign(assigns) 12 | |> assign(:values, input_value(form, field) || []) 13 | 14 | {:ok, socket} 15 | end 16 | 17 | @impl true 18 | def update(assigns, socket) do 19 | {:ok, assign(socket, assigns)} 20 | end 21 | 22 | @impl true 23 | def render(assigns = %{disabled: true}) do 24 | ~H""" 25 |
26 | 27 | 32 | 33 |
34 | """ 35 | end 36 | 37 | @impl true 38 | def render(assigns) do 39 | ~H""" 40 |
41 | <%= for {item, idx} <- Enum.with_index(@values) do %> 42 |
43 | 54 | <%= text_input(:form, :array, 55 | id: input_id(@form, @field) <> "_#{idx}", 56 | name: input_name(@form, @field) <> "[]", 57 | value: item, 58 | phx_debounce: 200 59 | ) %> 60 |
61 | <% end %> 62 | 72 |
73 | """ 74 | end 75 | 76 | @impl true 77 | def handle_event("add", _params, socket) do 78 | socket = assign(socket, values: socket.assigns.values ++ [""]) 79 | 80 | {:noreply, socket} 81 | end 82 | 83 | def handle_event("remove", params, socket) do 84 | idx = params |> Map.fetch!("idx") 85 | 86 | socket = 87 | socket 88 | |> assign(values: List.delete_at(socket.assigns.values, idx)) 89 | |> push_event("change", %{}) 90 | 91 | {:noreply, socket} 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.11.1" 5 | 6 | def project do 7 | [ 8 | app: :live_admin, 9 | name: "LiveAdmin", 10 | description: "A admin UI for Phoenix applications built with LiveView", 11 | version: @version, 12 | elixir: "~> 1.11", 13 | start_permanent: Mix.env() == :prod, 14 | elixirc_paths: elixirc_paths(Mix.env()), 15 | deps: deps(), 16 | aliases: aliases(), 17 | package: [ 18 | maintainers: ["Thomas Floyd Wright"], 19 | licenses: ["Apache-2.0"], 20 | links: %{"GitHub" => "https://github.com/tfwright/live_admin"}, 21 | files: ~w(lib .formatter.exs mix.exs README* dist) 22 | ], 23 | source_url: "https://github.com/tfwright/live_admin", 24 | docs: [ 25 | main: "readme", 26 | extras: ["README.md"], 27 | source_ref: "v#{@version}" 28 | ], 29 | compilers: Mix.compilers() ++ compilers(Mix.env()) 30 | ] 31 | end 32 | 33 | # Run "mix help compile.app" to learn about applications. 34 | def application do 35 | [ 36 | mod: {LiveAdmin.Application, []}, 37 | extra_applications: [:logger] 38 | ] 39 | end 40 | 41 | # Run "mix help deps" to learn about dependencies. 42 | defp deps do 43 | [ 44 | {:phoenix_view, "~> 2.0"}, 45 | {:phoenix_live_view, "~> 0.20.0"}, 46 | {:ecto, "~> 3.10"}, 47 | {:ecto_sql, "~> 3.10"}, 48 | {:phoenix_ecto, "~> 4.4"}, 49 | {:gettext, "~> 0.22"}, 50 | {:plug_cowboy, "~> 2.0", only: :dev}, 51 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 52 | {:jason, "~> 1.3", only: [:dev, :test]}, 53 | {:ecto_psql_extras, "~> 0.7", only: [:dev, :test]}, 54 | {:faker, "~> 0.17", only: :dev}, 55 | {:floki, ">= 0.30.0", only: :test}, 56 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 57 | {:docout, github: "tfwright/docout", branch: "main", runtime: false, only: [:dev, :test]}, 58 | {:dialyxir, "~> 1.2", only: :dev, runtime: false}, 59 | {:mox, "~> 1.0", only: :test} 60 | ] 61 | end 62 | 63 | defp aliases do 64 | [ 65 | dev: ["run --no-halt dev.exs"] 66 | ] 67 | end 68 | 69 | defp compilers(env) do 70 | if env == :dev && System.get_env("LIVE_ADMIN_DEV") do 71 | [:docout] 72 | else 73 | [] 74 | end 75 | end 76 | 77 | defp elixirc_paths(env) do 78 | if env == :dev && System.get_env("LIVE_ADMIN_DEV") do 79 | ["lib", "dev"] 80 | else 81 | ["lib"] 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/live_admin/components/nav.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.Components.Nav do 2 | use Phoenix.LiveComponent 3 | use Phoenix.HTML 4 | 5 | import LiveAdmin, 6 | only: [resource_title: 1, route_with_params: 2, trans: 1] 7 | 8 | @impl true 9 | def render(assigns) do 10 | nested_resources = 11 | Enum.reduce(assigns.resources, %{}, fn {key, resource}, groups -> 12 | path = 13 | resource.__live_admin_config__(:schema) 14 | |> Module.split() 15 | |> case do 16 | list when length(list) == 1 -> list 17 | list -> Enum.drop(list, -1) 18 | end 19 | |> Enum.map(&Access.key(&1, %{})) 20 | 21 | update_in(groups, path, fn subs -> Map.put(subs, {key, resource}, %{}) end) 22 | end) 23 | 24 | assigns = assign(assigns, :nested_resources, nested_resources) 25 | 26 | ~H""" 27 | 47 | """ 48 | end 49 | 50 | defp nav_group(assigns) do 51 | ~H""" 52 | 69 | """ 70 | end 71 | 72 | defp open?(assigns, schema) do 73 | assigns.current_resource 74 | |> case do 75 | nil -> 76 | true 77 | 78 | resource -> 79 | resource.__live_admin_config__(:schema) 80 | |> Module.split() 81 | |> Enum.drop(-1) 82 | |> Enum.member?(schema) 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/live_admin/components/session/content.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.Components.Session.Content do 2 | use Phoenix.LiveComponent 3 | use Phoenix.HTML 4 | 5 | import LiveAdmin, only: [trans: 1] 6 | 7 | alias Ecto.Changeset 8 | alias LiveAdmin.Components.Container.Form.MapInput 9 | 10 | @impl true 11 | def render(assigns) do 12 | ~H""" 13 |
14 |
15 |

16 | <%= trans("Session") %> 17 |

18 |
19 | 20 | <.form 21 | :let={f} 22 | for={@changeset} 23 | as={:session} 24 | phx-submit={:save} 25 | phx-target={@myself} 26 | phx-change={:validate} 27 | class="resource__form" 28 | > 29 |
30 | <%= label(f, :id, trans("id"), class: "field__label") %> 31 | <%= textarea(f, :id, rows: 1, class: "field__text", disabled: true) %> 32 |
33 |
34 | <%= label(f, :prefix, trans("prefix"), class: "field__label") %> 35 | <%= textarea(f, :prefix, rows: 1, class: "field__text", disabled: true) %> 36 |
37 |
38 | <%= label(f, :locale, trans("locale"), class: "field__label") %> 39 | <%= textarea(f, :locale, rows: 1, class: "field__text", disabled: true) %> 40 |
41 |
42 | <%= label(f, :metadata, trans("metadata"), class: "field__label") %> 43 | <.live_component module={MapInput} id="metadata" form={f} field={:metadata} /> 44 |
45 |
46 | <%= submit(trans("Save"), class: "resource__action--btn") %> 47 |
48 | 49 |
50 | """ 51 | end 52 | 53 | @impl true 54 | def handle_event("validate", %{"session" => params}, socket = %{assigns: %{}}) do 55 | changeset = 56 | socket.assigns.changeset 57 | |> Changeset.cast(params, [:metadata]) 58 | |> Changeset.update_change(:metadata, &parse_map_param/1) 59 | 60 | {:noreply, assign(socket, :changeset, changeset)} 61 | end 62 | 63 | @impl true 64 | def handle_event("save", params, socket) do 65 | session = 66 | socket.assigns.changeset 67 | |> Changeset.cast(params["session"] || %{}, [:metadata, :locale]) 68 | |> Changeset.update_change(:metadata, &parse_map_param/1) 69 | |> Changeset.apply_action!(:insert) 70 | 71 | LiveAdmin.session_store().persist!(session) 72 | 73 | {:noreply, 74 | socket 75 | |> assign(:changeset, Changeset.change(session)) 76 | |> push_event("success", %{msg: "Changes successfully saved"})} 77 | end 78 | 79 | defp parse_map_param(param = %{}) do 80 | param 81 | |> Enum.sort_by(fn {idx, _} -> idx end) 82 | |> Map.new(fn {_, %{"key" => key, "value" => value}} -> {key, value} end) 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/live_admin.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin do 2 | @moduledoc docout: [LiveAdmin.READMECompiler] 3 | 4 | def route_with_params(assigns, parts \\ []) do 5 | resource_path = parts[:resource_path] || assigns.key 6 | 7 | encoded_params = 8 | parts 9 | |> Keyword.get(:params, %{}) 10 | |> Enum.into(%{}) 11 | |> then(fn params -> 12 | if assigns[:prefix] do 13 | Map.put_new(params, :prefix, assigns[:prefix]) 14 | else 15 | params 16 | end 17 | end) 18 | |> Enum.into(%{}) 19 | |> Enum.flat_map(fn 20 | {_, nil} -> [] 21 | {:sort_attr, val} -> [{:"sort-attr", val}] 22 | {:sort_dir, val} -> [{:"sort-dir", val}] 23 | {:search, val} -> [{:s, val}] 24 | pair -> [pair] 25 | end) 26 | |> Enum.into(%{}) 27 | |> case do 28 | params when map_size(params) > 0 -> "?" <> Plug.Conn.Query.encode(params) 29 | _ -> "" 30 | end 31 | 32 | Path.join( 33 | [assigns.base_path, resource_path] ++ 34 | Enum.map(parts[:segments] || [], &Phoenix.Param.to_param/1) 35 | ) <> 36 | encoded_params 37 | end 38 | 39 | def session_store, 40 | do: Application.get_env(:live_admin, :session_store, __MODULE__.Session.Agent) 41 | 42 | def associated_resource(schema, field_name, resources, part \\ nil) do 43 | with %{related: assoc_schema} <- 44 | schema |> parent_associations() |> Enum.find(&(&1.owner_key == field_name)), 45 | config when not is_nil(config) <- 46 | Enum.find(resources, fn {_, resource} -> 47 | resource.__live_admin_config__(:schema) == assoc_schema 48 | end) do 49 | case part do 50 | nil -> config 51 | :key -> elem(config, 0) 52 | :resource -> elem(config, 1) 53 | end 54 | else 55 | _ -> nil 56 | end 57 | end 58 | 59 | def parent_associations(schema) do 60 | Enum.flat_map(schema.__schema__(:associations), fn assoc_name -> 61 | case schema.__schema__(:association, assoc_name) do 62 | assoc = %{relationship: :parent} -> [assoc] 63 | _ -> [] 64 | end 65 | end) 66 | end 67 | 68 | def resource_title(resource) do 69 | :title_with 70 | |> resource.__live_admin_config__() 71 | |> case do 72 | nil -> resource.__live_admin_config__(:schema) |> Module.split() |> Enum.at(-1) 73 | {m, f, a} -> apply(m, f, a) 74 | title when is_binary(title) -> title 75 | end 76 | end 77 | 78 | def record_label(nil, _), do: nil 79 | 80 | def record_label(record, resource) do 81 | :label_with 82 | |> resource.__live_admin_config__() 83 | |> case do 84 | {m, f, a} -> apply(m, f, [record | a]) 85 | label when is_atom(label) -> Map.fetch!(record, label) 86 | end 87 | end 88 | 89 | def use_i18n?, do: gettext_backend() != LiveAdmin.Gettext 90 | 91 | def trans(string, opts \\ []) do 92 | args = 93 | [gettext_backend(), string] 94 | |> then(fn base_args -> 95 | if opts[:inter], do: base_args ++ [opts[:inter]], else: base_args 96 | end) 97 | 98 | apply(Gettext, :gettext, args) 99 | end 100 | 101 | def gettext_backend, do: Application.get_env(:live_admin, :gettext_backend, LiveAdmin.Gettext) 102 | 103 | def resources(router, base_path) do 104 | router 105 | |> Phoenix.Router.routes() 106 | |> Enum.flat_map(fn 107 | %{metadata: %{base_path: ^base_path, resource: resource}} -> [resource] 108 | _ -> [] 109 | end) 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/live_admin/components/resource/form/map_input.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.Components.Container.Form.MapInput do 2 | use Phoenix.LiveComponent 3 | use Phoenix.HTML 4 | 5 | import LiveAdmin, only: [trans: 1] 6 | 7 | alias Phoenix.LiveView.JS 8 | 9 | @impl true 10 | def mount(socket) do 11 | {:ok, assign(socket, :values, %{})} 12 | end 13 | 14 | @impl true 15 | def update(assigns = %{form: form, field: field}, socket) do 16 | values = 17 | Map.get(form.params, to_string(field)) || 18 | build_values_from_input_value(input_value(form, field)) || 19 | %{} 20 | 21 | socket = 22 | socket 23 | |> assign(assigns) 24 | |> assign(:values, values) 25 | |> assign(:disabled, Enum.any?(values, fn {_, %{"value" => v}} -> is_map(v) end)) 26 | 27 | {:ok, socket} 28 | end 29 | 30 | @impl true 31 | def update(assigns, socket) do 32 | {:ok, assign(socket, assigns)} 33 | end 34 | 35 | @impl true 36 | def render(assigns = %{disabled: true}) do 37 | ~H""" 38 |
39 | 40 |
<%= @form |> input_value(@field) |> inspect() %>
41 |
42 |
43 | """ 44 | end 45 | 46 | @impl true 47 | def render(assigns) do 48 | ~H""" 49 |
50 |
51 | <%= for {idx, %{"key" => k, "value" => v}} <- Enum.sort(@values) do %> 52 | 77 | <% end %> 78 | 88 |
89 |
90 | """ 91 | end 92 | 93 | @impl true 94 | def handle_event("add", _, socket) do 95 | socket = 96 | socket 97 | |> update( 98 | :values, 99 | &Map.put(&1, &1 |> map_size() |> to_string(), %{"key" => nil, "value" => nil}) 100 | ) 101 | |> push_event("change", %{}) 102 | 103 | {:noreply, socket} 104 | end 105 | 106 | @impl true 107 | def handle_event("remove", %{"idx" => idx}, socket) do 108 | socket = 109 | socket 110 | |> update(:values, &remove_at(&1, idx)) 111 | |> push_event("change", %{}) 112 | 113 | {:noreply, socket} 114 | end 115 | 116 | defp remove_at(values, idx) do 117 | values 118 | |> Map.delete(idx) 119 | |> Enum.with_index() 120 | |> Map.new(fn {{_, value}, idx} -> 121 | {to_string(idx), value} 122 | end) 123 | end 124 | 125 | defp build_values_from_input_value(nil), do: nil 126 | 127 | defp build_values_from_input_value(value) do 128 | value 129 | |> Enum.with_index() 130 | |> Map.new(fn {{k, v}, idx} -> 131 | {to_string(idx), %{"key" => k, "value" => v}} 132 | end) 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/live_admin/components/resource/form/search_select.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.Components.Container.Form.SearchSelect do 2 | use Phoenix.LiveComponent 3 | use Phoenix.HTML 4 | 5 | import LiveAdmin, only: [record_label: 2, trans: 1] 6 | 7 | alias Phoenix.LiveView.JS 8 | alias LiveAdmin.Resource 9 | 10 | @impl true 11 | def update(assigns = %{form: form, field: field}, socket) do 12 | socket = 13 | socket 14 | |> assign(assigns) 15 | |> assign(options: []) 16 | |> assign_selected_option(input_value(form, field)) 17 | 18 | {:ok, socket} 19 | end 20 | 21 | @impl true 22 | def render(assigns = %{disabled: true}) do 23 | ~H""" 24 |
25 | <%= if @selected_option do %> 26 | <%= record_label(@selected_option, @resource) %> 27 | <% else %> 28 | <%= trans("None") %> 29 | <% end %> 30 |
31 | """ 32 | end 33 | 34 | @impl true 35 | def render(assigns) do 36 | ~H""" 37 |
38 | <%= hidden_input(@form, @field, 39 | disabled: @disabled, 40 | value: if(@selected_option, do: @selected_option.id) 41 | ) %> 42 | <%= if @selected_option do %> 43 | 48 | <%= record_label(@selected_option, @resource) %> 49 | <% else %> 50 |
51 | "_search_select"} 54 | disabled={@disabled} 55 | placeholder={trans("Search")} 56 | autocomplete="off" 57 | phx-focus="load_options" 58 | phx-keyup="load_options" 59 | phx-target={@myself} 60 | phx-debounce={200} 61 | /> 62 |
63 | 82 |
83 |
84 | <% end %> 85 |
86 | """ 87 | end 88 | 89 | @impl true 90 | def handle_event( 91 | "load_options", 92 | %{"value" => q}, 93 | socket = %{assigns: %{resource: resource, session: session}} 94 | ) do 95 | options = 96 | resource 97 | |> Resource.list([search: q, prefix: socket.assigns.prefix], session, socket.assigns.repo) 98 | |> elem(0) 99 | 100 | {:noreply, assign(socket, :options, options)} 101 | end 102 | 103 | def handle_event("select", %{"id" => id}, socket) do 104 | socket = 105 | socket 106 | |> assign_selected_option(id) 107 | |> push_event("change", %{}) 108 | 109 | {:noreply, socket} 110 | end 111 | 112 | defp assign_selected_option(socket, id) when id in [nil, ""], 113 | do: assign(socket, :selected_option, nil) 114 | 115 | defp assign_selected_option( 116 | socket = %{assigns: %{selected_option: %{id: selected_option_id}}}, 117 | id 118 | ) 119 | when selected_option_id == id, 120 | do: socket 121 | 122 | defp assign_selected_option(socket, id), 123 | do: 124 | assign( 125 | socket, 126 | :selected_option, 127 | Resource.find!(id, socket.assigns.resource, socket.assigns.prefix, socket.assigns.repo) 128 | ) 129 | end 130 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | pg_url = System.get_env("PG_URL") || "postgres:postgres@127.0.0.1" 2 | 3 | Application.put_env(:live_admin, LiveAdminTest.Repo, 4 | url: "ecto://#{pg_url}/phx_admin_dev", 5 | pool: Ecto.Adapters.SQL.Sandbox 6 | ) 7 | 8 | defmodule LiveAdminTest.Repo do 9 | use Ecto.Repo, otp_app: :live_admin, adapter: Ecto.Adapters.Postgres 10 | 11 | def prefixes, do: ["alt"] 12 | end 13 | 14 | _ = Ecto.Adapters.Postgres.storage_up(LiveAdminTest.Repo.config()) 15 | 16 | Application.put_env(:live_admin, LiveAdminTest.Endpoint, 17 | url: [host: "localhost", port: 4000], 18 | secret_key_base: "Hu4qQN3iKzTV4fJxhorPQlA/osH9fAMtbtjVS58PFgfw3ja5Z18Q/WSNR9wP4OfW", 19 | live_view: [signing_salt: "hMegieSe"], 20 | render_errors: [view: LiveAdminTest.ErrorView], 21 | check_origin: false, 22 | pubsub_server: LiveAdminTest.PubSub 23 | ) 24 | 25 | defmodule LiveAdminTest.ErrorView do 26 | use Phoenix.View, root: "test/templates" 27 | 28 | def template_not_found(template, _assigns) do 29 | Phoenix.Controller.status_message_from_template(template) 30 | end 31 | end 32 | 33 | defmodule LiveAdminTest.Router do 34 | use Phoenix.Router 35 | 36 | import LiveAdmin.Router 37 | 38 | pipeline :browser do 39 | plug(:fetch_session) 40 | end 41 | 42 | scope "/" do 43 | pipe_through(:browser) 44 | 45 | live_admin "/" do 46 | admin_resource("/user", LiveAdminTest.User) 47 | admin_resource("/live_admin_test_post", LiveAdminTest.PostResource) 48 | end 49 | end 50 | end 51 | 52 | defmodule LiveAdminTest.Endpoint do 53 | use Phoenix.Endpoint, otp_app: :live_admin 54 | 55 | plug(Plug.Session, 56 | store: :cookie, 57 | key: "_live_view_key", 58 | signing_salt: "/VEDsdfsffMnp5" 59 | ) 60 | 61 | plug(LiveAdminTest.Router) 62 | end 63 | 64 | defmodule LiveAdminTest.User do 65 | use Ecto.Schema 66 | 67 | use LiveAdmin.Resource, 68 | immutable_fields: [:encrypted_password], 69 | actions: [:user_action] 70 | 71 | @primary_key {:id, :binary_id, autogenerate: true} 72 | schema "users" do 73 | field(:name, :string) 74 | field(:encrypted_password, :string) 75 | 76 | belongs_to(:other_resource, OtherResource) 77 | 78 | embeds_one(:settings, LiveAdminTest.Settings) 79 | end 80 | 81 | def user_action(%__MODULE__{}, %{}), do: {:ok, "worked"} 82 | end 83 | 84 | defmodule LiveAdminTest.PostResource do 85 | use LiveAdmin.Resource, 86 | schema: LiveAdminTest.Post, 87 | actions: [:run_action] 88 | 89 | def run_action(_, _), do: {:ok, "worked"} 90 | end 91 | 92 | defmodule LiveAdminTest.Post do 93 | use Ecto.Schema 94 | 95 | schema "posts" do 96 | field(:title, :string) 97 | belongs_to(:user, LiveAdminTest.User, type: :binary_id) 98 | 99 | embeds_many(:previous_versions, __MODULE__.Version, on_replace: :delete) 100 | end 101 | end 102 | 103 | defmodule LiveAdminTest.Settings do 104 | use Ecto.Schema 105 | 106 | embedded_schema do 107 | field(:some_option, :string) 108 | field(:metadata, :map) 109 | end 110 | end 111 | 112 | defmodule LiveAdminTest.Post.Version do 113 | use Ecto.Schema 114 | 115 | @primary_key false 116 | embedded_schema do 117 | field(:body, :string) 118 | field(:tags, {:array, :string}) 119 | 120 | timestamps(updated_at: false) 121 | end 122 | end 123 | 124 | defmodule LiveAdminTest.StubSession do 125 | @behaviour LiveAdmin.Session.Store 126 | 127 | def init!(_), do: "fake" 128 | def load!(_), do: %LiveAdmin.Session{} 129 | def persist!(_), do: :ok 130 | end 131 | 132 | Mox.defmock(LiveAdminTest.MockSession, for: LiveAdmin.Session.Store, skip_optional_callbacks: true) 133 | 134 | Application.ensure_all_started(:os_mon) 135 | 136 | Application.put_env(:live_admin, :ecto_repo, LiveAdminTest.Repo) 137 | Application.put_env(:live_admin, :session_store, LiveAdminTest.MockSession) 138 | 139 | Supervisor.start_link( 140 | [ 141 | LiveAdminTest.Repo, 142 | {Phoenix.PubSub, name: LiveAdminTest.PubSub, adapter: Phoenix.PubSub.PG2}, 143 | LiveAdminTest.Endpoint 144 | ], 145 | strategy: :one_for_one 146 | ) 147 | 148 | LiveAdminTest.Repo.delete_all(LiveAdminTest.User) 149 | LiveAdminTest.Repo.delete_all(LiveAdminTest.Post) 150 | LiveAdminTest.Repo.delete_all(LiveAdminTest.User, prefix: "alt") 151 | LiveAdminTest.Repo.delete_all(LiveAdminTest.Post, prefix: "alt") 152 | 153 | ExUnit.start() 154 | 155 | Ecto.Adapters.SQL.Sandbox.mode(LiveAdminTest.Repo, :manual) 156 | -------------------------------------------------------------------------------- /lib/live_admin/components.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.Components do 2 | use Phoenix.Component 3 | use Phoenix.HTML 4 | 5 | alias LiveAdmin.Components.Container.Form 6 | alias Phoenix.LiveView.JS 7 | 8 | slot(:inner_block, required: true) 9 | 10 | attr(:label, :string, required: true) 11 | attr(:disabled, :boolean, default: false) 12 | attr(:items, :list, default: []) 13 | attr(:orientation, :atom, values: [:up, :down], default: :down) 14 | attr(:id, :string, default: nil) 15 | 16 | def dropdown(assigns) do 17 | ~H""" 18 |
19 | <%= if @orientation == :up do %> 20 | <.list items={@items} inner_block={@inner_block} /> 21 | <% end %> 22 | 28 | <%= if @orientation == :down do %> 29 | <.list items={@items} inner_block={@inner_block} /> 30 | <% end %> 31 |
32 | """ 33 | end 34 | 35 | def embed(assigns) do 36 | ~H""" 37 |
"_container"} class="embed__group" phx-hook="EmbedComponent"> 38 | <%= unless @disabled do %> 39 | <.inputs_for :let={embed_form} field={@form[@field]} skip_hidden={true}> 40 |
41 | <%= if match?({_, _, %{cardinality: :many}}, @type) do %> 42 | "[]"} 45 | value={embed_form.index} 46 | class="embed__index" 47 | phx-page-loading 48 | /> 49 | "[]"} 52 | value={embed_form.index} 53 | class="embed__drop" 54 | phx-page-loading 55 | /> 56 | 57 | <%= if embed_form.index > 0 do %> 58 | 64 | <% end %> 65 | <%= if embed_form.index < Enum.count(List.wrap(input_value(@form, @field))) - 1 do %> 66 | 72 | <% end %> 73 | <% else %> 74 | 75 | 76 | <% end %> 77 |
78 | <%= for {field, type, _} <- embed_fields(@type) do %> 79 | 90 | <% end %> 91 |
92 |
93 | 94 | <%= if match?({_, _, %{cardinality: :many}}, @type) || !input_value(@form, @field) do %> 95 | "[]"} 98 | class="embed__sort" 99 | phx-page-loading 100 | /> 101 | 102 | <% end %> 103 | <% else %> 104 |
<%= @form |> input_value(@field) |> inspect() %>
105 | <% end %> 106 |
107 | """ 108 | end 109 | 110 | defp list(assigns) do 111 | ~H""" 112 |
113 | 120 |
121 | """ 122 | end 123 | 124 | defp embed_fields({_, _, %{related: schema}}), 125 | do: Enum.map(schema.__schema__(:fields), &{&1, schema.__schema__(:type, &1), []}) 126 | end 127 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | import "phoenix_html" 2 | import {Socket} from "phoenix" 3 | import {LiveSocket} from "phoenix_live_view" 4 | import ClipboardJS from 'clipboard' 5 | import Toastify from 'toastify-js' 6 | import topbar from "topbar" 7 | 8 | topbar.config({barColors: {0: "rgb(67, 56, 202)"}, shadowColor: "rgba(0, 0, 0, .3)", className: "topbar"}) 9 | window.addEventListener("phx:page-loading-start", info => topbar.show()) 10 | window.addEventListener("phx:page-loading-stop", () => { 11 | document.activeElement.blur(); 12 | topbar.hide(); 13 | }) 14 | window.addEventListener("phx:success", (e) => { 15 | Toastify({ 16 | text: e.detail.msg, 17 | className: "toast__container--success", 18 | }).showToast(); 19 | }) 20 | window.addEventListener("phx:error", (e) => { 21 | Toastify({ 22 | text: e.detail.msg, 23 | className: "toast__container--error", 24 | }).showToast(); 25 | }) 26 | 27 | let Hooks = {} 28 | 29 | Hooks.EmbedComponent = { 30 | mounted() { 31 | this.el.addEventListener("live_admin:move_embed", e => { 32 | const embedEl = e.target.parentElement; 33 | const indexEl = embedEl.querySelector(".embed__index"); 34 | const fieldEl = embedEl.parentElement; 35 | 36 | const newIndex = +indexEl.value + +e.target.dataset.dir; 37 | indexEl.value = newIndex; 38 | 39 | const targetEl = fieldEl.querySelectorAll(".embed__index")[newIndex] 40 | targetEl.value = +targetEl.value + (+e.target.dataset.dir * -1) 41 | 42 | indexEl.dispatchEvent(new Event("input", {bubbles: true, cancelable: true})); 43 | }); 44 | 45 | this.el.addEventListener("live_admin:embed_add", e => { 46 | const sortInput = e.target.previousElementSibling; 47 | sortInput.checked = true; 48 | sortInput.dispatchEvent(new Event("input", {bubbles: true, cancelable: true})); 49 | }); 50 | 51 | this.el.addEventListener("live_admin:embed_drop", e => { 52 | e.target.parentElement.classList.add("hidden") 53 | const deleteInput = e.target.previousElementSibling; 54 | deleteInput.checked = true; 55 | deleteInput.dispatchEvent(new Event("input", {bubbles: true, cancelable: true})); 56 | }); 57 | 58 | this.el.addEventListener("live_admin:embed_delete", e => { 59 | e.target.nextElementSibling.remove(); 60 | 61 | const deleteInput = e.target.previousElementSibling; 62 | deleteInput.disabled = false; 63 | deleteInput.dispatchEvent(new Event("input", {bubbles: true, cancelable: true})); 64 | }); 65 | } 66 | } 67 | 68 | Hooks.FormPage = { 69 | mounted(){ 70 | this.handleEvent("change", () => { 71 | this.el.querySelector('input').dispatchEvent(new Event("input", {bubbles: true, cancelable: true})) 72 | }) 73 | } 74 | } 75 | 76 | Hooks.ViewPage = { 77 | mounted() { 78 | this.el.addEventListener("live_admin:action", e => { 79 | this.pushEventTo(this.el, "action", {action: e.target.dataset.action, ids: this.selected}); 80 | }) 81 | } 82 | } 83 | 84 | Hooks.IndexPage = { 85 | mounted() { 86 | this.selected = []; 87 | 88 | this.el.addEventListener("live_admin:action", e => { 89 | if (e.target.dataset.action === "delete") { 90 | this.pushEventTo(this.el, "delete", {ids: this.selected}); 91 | } else { 92 | this.pushEventTo(this.el, "action", {action: e.target.dataset.action, ids: this.selected}); 93 | } 94 | }) 95 | 96 | this.el.addEventListener("live_admin:toggle_select", e => { 97 | if (e.target.id === "select-all") { 98 | this.el.querySelectorAll('.resource__select').forEach(box => box.checked = e.target.checked); 99 | } else { 100 | this.el.querySelector('#select-all').checked = false; 101 | } 102 | 103 | this.selected = Array.from(this.el.querySelectorAll('input[data-record-id]:checked'), e => e.dataset.recordId); 104 | 105 | if (this.selected.length > 0) { 106 | document.getElementById("footer-select").classList.remove("hidden"); 107 | document.getElementById("footer-nav").classList.add("hidden"); 108 | } else { 109 | document.getElementById("footer-nav").classList.remove("hidden"); 110 | document.getElementById("footer-select").classList.add("hidden"); 111 | } 112 | }); 113 | 114 | var clipboard = new ClipboardJS( 115 | this.el.querySelectorAll('.cell__copy'), 116 | { 117 | target: function (trigger) { 118 | return trigger.closest('.resource__cell').firstElementChild 119 | }, 120 | } 121 | ); 122 | 123 | clipboard.on('success', function(e) { 124 | Toastify({ 125 | text: e.trigger.dataset.message, 126 | className: "toast__container", 127 | }).showToast(); 128 | }); 129 | }, 130 | updated() { 131 | this.selected = []; 132 | } 133 | } 134 | 135 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 136 | let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks, params: {_csrf_token: csrfToken}}) 137 | 138 | // Connect if there are any LiveViews on the page 139 | liveSocket.connect() 140 | 141 | if (ENV == "dev") { 142 | liveSocket.enableDebug() 143 | liveSocket.enableLatencySim(200 + Math.floor(Math.random() * 1500)) 144 | } 145 | 146 | window.liveSocket = liveSocket 147 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # LiveAdmin 4 | 5 | [![hex package](https://img.shields.io/hexpm/v/live_admin.svg)](https://hex.pm/packages/live_admin) 6 | [![CI status](https://github.com/tfwright/live_admin/workflows/CI/badge.svg)](https://github.com/tfwright/live_admin/actions) 7 | 8 | An admin UI for Phoenix applications built on [Phoenix LiveView](https://github.com/phoenixframework/phoenix_live_view) and [Ecto](https://github.com/elixir-ecto/ecto/). 9 | 10 | Significant features: 11 | 12 | * First class support for multi tenant applications via Ecto's `prefix` option 13 | * Overridable views and API 14 | * Easily add custom actions at the schema and record level 15 | * Ability to edit (nested) embedded schemas 16 | * i18n via [Gettext](elixir-gettext/gettext) 17 | 18 | ## Installation 19 | 20 | First, ensure your Phoenix app has been configured to use [LiveView](https://hexdocs.pm/phoenix_live_view/installation.html). 21 | 22 | Add to your app's `deps`: 23 | 24 | ```elixir 25 | {:live_admin, "~> 0.11.1"} 26 | ``` 27 | 28 | Configure a module to act as a LiveAdmin resource: 29 | 30 | ```elixir 31 | defmodule MyApp.MyResource do 32 | use LiveAdmin.Resource, schema: MyApp.Schema 33 | end 34 | ``` 35 | 36 | *Note: if you use an Ecto schema you can omit the `schema' option.* 37 | 38 | In your Phoenix router, inside a scope configured to run LiveView (`:browser` if you followed the default installation), add the resource to a LiveAdmin instance: 39 | 40 | ```elixir 41 | import LiveAdmin.Router 42 | 43 | scope "/" do 44 | pipe_through: :browser 45 | 46 | live_admin "/my_admin" do 47 | admin_resource "/my_schemas", MyApp.MyResource 48 | end 49 | end 50 | ``` 51 | 52 | Finally, tell LiveAdmin what Ecto repo to use to run queries in your `runtime.ex`: `config :live_admin, ecto_repo: MyApp.Repo` 53 | 54 | That's it, now an admin UI for `MyApp.Schema` will be available at `/my_admin/my_schemas`. 55 | 56 | ## Configuration 57 | 58 | One of the main goals of LiveAdmin is to require as little config as possible. 59 | It should work out of the box, with only the above, for the vast majority of common 60 | app admin needs. 61 | 62 | However, if you want to customize the behavior of one or more resources, including how records 63 | are rendered or changes are validated, or to add custom behaviors, there are a variety of configuration options 64 | available. This includes component overrides if you would like to completely control 65 | every aspect of a particular resource view, like the edit form. For a complete list of options, see the `LiveAdmin.Resource` docs. 66 | 67 | For additional convenience and control, configuration in LiveAdmin can be set at 3 different levels. 68 | From most specific to most general, they are resource, admin instance, and global. 69 | 70 | For concrete examples of the various config options and to see them in action, consult the [development app](#development-environment). 71 | 72 | ### Resource 73 | 74 | The second argument passed to `use LiveAdmin.Resource` will configure only that specific resource, 75 | in any LiveView it is used. If the module is not an Ecto schema, the `:schema` option must be passed. 76 | If you would like the same schema to behave differently in different LiveAdmin instances, or different 77 | routes in the same instance, you must create multiple resource modules to contain that configuration. 78 | 79 | ### Admin instance 80 | 81 | The second argument passed to `live_admin` will configure defaults for all resources in the group 82 | that do not specify the same configuration. Currently only component overrides and the repo can be 83 | configured at this level. 84 | 85 | ### Global 86 | 87 | All resource configuration options can also be set in the LiveAdmin app runtime config. This will set a global 88 | default to apply to all resources unless overridden in their individual config, or the LiveAdmin instance. 89 | 90 | Additionally, the following options can only be set at the global level: 91 | 92 | * `css_overrides` - a binary or MFA identifying a function that returns CSS to be appended to app css 93 | * `session_store` - a module implementing the `LiveAdmin.Session.Store` behavior, used to persist session data 94 | * `gettext_backend` - a module implementing the [Gettext API](https://hexdocs.pm/gettext/Gettext.html#module-gettext-api). It is expected to implement `locales/0` returning a list of binary locale names 95 | 96 | ## i18n 97 | 98 | LiveAdmin wraps all static strings in the UI with Gettext calls, but currently it does *not* provide any locales by default, so you will need 99 | to make sure they have been set up correctly for a custom backend. Unfortunately it is not currently possible to use 100 | Gettext's utilities to automatically extract the pot files so you will need to do this manually. 101 | To avoid conflicts with your own app's translations, it is recommended to create separate Gettext backends for LiveAdmin. 102 | 103 | ## Development environment 104 | 105 | This repo has been configured to run the application in [Docker](https://www.docker.com/) using [Compose](https://docs.docker.com/compose/). 106 | 107 | The Phoenix app is running the `app` service, so all mix command should be run there. Examples: 108 | 109 | * `docker compose run web mix test` 110 | 111 | --- 112 | 113 | README generated with [docout](https://github.com/tfwright/docout) 114 | -------------------------------------------------------------------------------- /README.md.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | # LiveAdmin 4 | 5 | [![hex package](https://img.shields.io/hexpm/v/live_admin.svg)](https://hex.pm/packages/live_admin) 6 | [![CI status](https://github.com/tfwright/live_admin/workflows/CI/badge.svg)](https://github.com/tfwright/live_admin/actions) 7 | 8 | An admin UI for Phoenix applications built on [Phoenix LiveView](https://github.com/phoenixframework/phoenix_live_view) and [Ecto](https://github.com/elixir-ecto/ecto/). 9 | 10 | Significant features: 11 | 12 | * First class support for multi tenant applications via Ecto's `prefix` option 13 | * Overridable views and API 14 | * Easily add custom actions at the schema and record level 15 | * Ability to edit (nested) embedded schemas 16 | * i18n via [Gettext](elixir-gettext/gettext) 17 | 18 | ## Installation 19 | 20 | First, ensure your Phoenix app has been configured to use [LiveView](https://hexdocs.pm/phoenix_live_view/installation.html). 21 | 22 | Add to your app's `deps`: 23 | 24 | ```elixir 25 | {:live_admin, "~> <%= app_version %>"} 26 | ``` 27 | 28 | Configure a module to act as a LiveAdmin resource: 29 | 30 | ```elixir 31 | defmodule MyApp.MyResource do 32 | use LiveAdmin.Resource, schema: MyApp.Schema 33 | end 34 | ``` 35 | 36 | *Note: if you use an Ecto schema you can omit the `schema' option.* 37 | 38 | In your Phoenix router, inside a scope configured to run LiveView (`:browser` if you followed the default installation), add the resource to a LiveAdmin instance: 39 | 40 | ```elixir 41 | import LiveAdmin.Router 42 | 43 | scope "/" do 44 | pipe_through: :browser 45 | 46 | live_admin "/my_admin" do 47 | admin_resource "/my_schemas", MyApp.MyResource 48 | end 49 | end 50 | ``` 51 | 52 | Finally, tell LiveAdmin what Ecto repo to use to run queries in your `runtime.ex`: `config :live_admin, ecto_repo: MyApp.Repo` 53 | 54 | That's it, now an admin UI for `MyApp.Schema` will be available at `/my_admin/my_schemas`. 55 | 56 | ## Configuration 57 | 58 | One of the main goals of LiveAdmin is to require as little config as possible. 59 | It should work out of the box, with only the above, for the vast majority of common 60 | app admin needs. 61 | 62 | However, if you want to customize the behavior of one or more resources, including how records 63 | are rendered or changes are validated, or to add custom behaviors, there are a variety of configuration options 64 | available. This includes component overrides if you would like to completely control 65 | every aspect of a particular resource view, like the edit form. For a complete list of options, see the `LiveAdmin.Resource` docs. 66 | 67 | For additional convenience and control, configuration in LiveAdmin can be set at 3 different levels. 68 | From most specific to most general, they are resource, admin instance, and global. 69 | 70 | For concrete examples of the various config options and to see them in action, consult the [development app](#development-environment). 71 | 72 | ### Resource 73 | 74 | The second argument passed to `use LiveAdmin.Resource` will configure only that specific resource, 75 | in any LiveView it is used. If the module is not an Ecto schema, the `:schema` option must be passed. 76 | If you would like the same schema to behave differently in different LiveAdmin instances, or different 77 | routes in the same instance, you must create multiple resource modules to contain that configuration. 78 | 79 | ### Admin instance 80 | 81 | The second argument passed to `live_admin` will configure defaults for all resources in the group 82 | that do not specify the same configuration. Currently only component overrides and the repo can be 83 | configured at this level. 84 | 85 | ### Global 86 | 87 | All resource configuration options can also be set in the LiveAdmin app runtime config. This will set a global 88 | default to apply to all resources unless overridden in their individual config, or the LiveAdmin instance. 89 | 90 | Additionally, the following options can only be set at the global level: 91 | 92 | * `css_overrides` - a binary or MFA identifying a function that returns CSS to be appended to app css 93 | * `session_store` - a module implementing the `LiveAdmin.Session.Store` behavior, used to persist session data 94 | * `gettext_backend` - a module implementing the [Gettext API](https://hexdocs.pm/gettext/Gettext.html#module-gettext-api). It is expected to implement `locales/0` returning a list of binary locale names 95 | 96 | ## i18n 97 | 98 | LiveAdmin wraps all static strings in the UI with Gettext calls, but currently it does *not* provide any locales by default, so you will need 99 | to make sure they have been set up correctly for a custom backend. Unfortunately it is not currently possible to use 100 | Gettext's utilities to automatically extract the pot files so you will need to do this manually. 101 | To avoid conflicts with your own app's translations, it is recommended to create separate Gettext backends for LiveAdmin. 102 | 103 | ## Development environment 104 | 105 | This repo has been configured to run the application in [Docker](https://www.docker.com/) using [Compose](https://docs.docker.com/compose/). 106 | 107 | The Phoenix app is running the `app` service, so all mix command should be run there. Examples: 108 | 109 | * `docker compose run web mix test` 110 | 111 | --- 112 | 113 | README generated with [docout](https://github.com/tfwright/docout) 114 | -------------------------------------------------------------------------------- /lib/live_admin/router.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.Router do 2 | import Phoenix.Component, only: [assign: 2] 3 | 4 | @doc """ 5 | Defines a group of LiveAdmin resources that share a common path prefix, and optionally, configuration. 6 | 7 | ## Arguments 8 | 9 | * `path` - Defines a scope to be added to the router under which the resources will be grouped in a single live session 10 | * `opts` - Opts for the Admin UI added at configured path 11 | * `:title` - Title for the UI home view (Default: 'LiveAdmin') 12 | * `:components` - Component overrides that will be used for every resource in the group 13 | unless a resource is configurated to use its own overrides. 14 | * `:repo` - An Ecto repo to use for queries within this group of admin resources, unless 15 | overriden by an individual resource 16 | * `on_mount` - A Tuple identifying a function with arity 1, that will be passed the socket and should return it 17 | """ 18 | defmacro live_admin(path, opts \\ [], do: context) do 19 | import Phoenix.LiveView.Router, only: [live: 4, live_session: 3] 20 | 21 | title = Keyword.get(opts, :title, "LiveAdmin") 22 | components = Keyword.get(opts, :components, Application.get_env(:live_admin, :components, [])) 23 | repo = Keyword.get(opts, :ecto_repo, Application.get_env(:live_admin, :ecto_repo)) 24 | on_mount = Keyword.get(opts, :on_mount) 25 | 26 | quote do 27 | current_path = 28 | __MODULE__ 29 | |> Module.get_attribute(:phoenix_top_scopes) 30 | |> Map.fetch!(:path) 31 | 32 | @base_path Path.join(["/", current_path, unquote(path)]) 33 | 34 | scope unquote(path), alias: false, as: false do 35 | live_session :"live_admin_#{@base_path}", 36 | session: 37 | {unquote(__MODULE__), :build_session, 38 | [@base_path, unquote(title), unquote(components), unquote(repo), unquote(on_mount)]}, 39 | root_layout: {LiveAdmin.View, :layout}, 40 | layout: {LiveAdmin.View, :app}, 41 | on_mount: {unquote(__MODULE__), :assign_options} do 42 | live("/", LiveAdmin.Components.Home, :"home_#{@base_path}", as: :"home_#{@base_path}") 43 | 44 | live("/session", LiveAdmin.Components.Session, :"session_#{@base_path}", 45 | as: :"session_#{@base_path}" 46 | ) 47 | 48 | unquote(context) 49 | end 50 | end 51 | end 52 | end 53 | 54 | @doc """ 55 | Defines a resource to be included in a LiveAdmin UI. 56 | 57 | For each configured resource at path `/foo`, the following routes will be added: 58 | 59 | * `/foo` - List view 60 | * `/foo/new` - New record form 61 | * `/foo/:id/edit` - Update record form 62 | """ 63 | defmacro admin_resource(path, resource_mod) do 64 | import Phoenix.LiveView.Router, only: [live: 4] 65 | 66 | quote bind_quoted: [path: path, resource_mod: resource_mod] do 67 | full_path = Path.join(@base_path, path) 68 | 69 | live(path, LiveAdmin.Components.Container, :list, 70 | as: :"list_#{full_path}", 71 | metadata: %{base_path: @base_path, resource: {path, resource_mod}} 72 | ) 73 | 74 | live("#{path}/new", LiveAdmin.Components.Container, :new, 75 | as: :"new_#{full_path}", 76 | metadata: %{base_path: @base_path, resource: {path, resource_mod}} 77 | ) 78 | 79 | live("#{path}/:record_id", LiveAdmin.Components.Container, :view, 80 | as: :"view_#{full_path}", 81 | metadata: %{base_path: @base_path, resource: {path, resource_mod}} 82 | ) 83 | 84 | live("#{path}/edit/:record_id", LiveAdmin.Components.Container, :edit, 85 | as: :"edit_#{full_path}", 86 | metadata: %{base_path: @base_path, resource: {path, resource_mod}} 87 | ) 88 | end 89 | end 90 | 91 | def build_session(conn, base_path, title, components, repo, on_mount) do 92 | %{ 93 | "session_id" => LiveAdmin.session_store().init!(conn), 94 | "base_path" => base_path, 95 | "title" => title, 96 | "components" => components |> add_default_components() |> Enum.into(%{}), 97 | "repo" => repo, 98 | "on_mount" => on_mount 99 | } 100 | end 101 | 102 | def on_mount( 103 | :assign_options, 104 | _params, 105 | %{ 106 | "title" => title, 107 | "base_path" => base_path, 108 | "components" => components, 109 | "session_id" => session_id, 110 | "repo" => repo, 111 | "on_mount" => on_mount 112 | }, 113 | socket 114 | ) do 115 | session = LiveAdmin.session_store().load!(session_id) 116 | 117 | Gettext.put_locale(LiveAdmin.gettext_backend(), session.locale) 118 | 119 | socket = 120 | assign(socket, 121 | session: session, 122 | base_path: base_path, 123 | title: title, 124 | nav_mod: Map.fetch!(components, :nav), 125 | resources: LiveAdmin.resources(socket.router, base_path), 126 | default_repo: repo 127 | ) 128 | 129 | socket = 130 | case on_mount do 131 | {m, f} -> apply(m, f, [socket]) 132 | _ -> socket 133 | end 134 | 135 | {:cont, socket} 136 | end 137 | 138 | defp add_default_components(components) do 139 | components 140 | |> Keyword.put_new(:nav, LiveAdmin.Components.Nav) 141 | |> Keyword.put_new(:home, LiveAdmin.Components.Home.Content) 142 | |> Keyword.put_new(:session, LiveAdmin.Components.Session.Content) 143 | |> Keyword.put_new(:new, LiveAdmin.Components.Container.Form) 144 | |> Keyword.put_new(:edit, LiveAdmin.Components.Container.Form) 145 | |> Keyword.put_new(:list, LiveAdmin.Components.Container.Index) 146 | |> Keyword.put_new(:view, LiveAdmin.Components.Container.View) 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /lib/live_admin/components/resource/view.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.Components.Container.View do 2 | use Phoenix.LiveComponent 3 | use Phoenix.HTML 4 | 5 | import LiveAdmin, 6 | only: [route_with_params: 1, route_with_params: 2, trans: 1, record_label: 2, trans: 2] 7 | 8 | import LiveAdmin.View, only: [field_class: 1] 9 | import LiveAdmin.Components 10 | 11 | alias LiveAdmin.Resource 12 | alias Phoenix.LiveView.JS 13 | 14 | @impl true 15 | def render(assigns = %{record: nil}) do 16 | ~H""" 17 |
<%= trans("No record found") %>
18 | """ 19 | end 20 | 21 | @impl true 22 | def render(assigns) do 23 | ~H""" 24 |
25 |
26 |
27 | <%= for {field, type, _} <- Resource.fields(@resource) do %> 28 | <% assoc_resource = 29 | LiveAdmin.associated_resource( 30 | @resource.__live_admin_config__(:schema), 31 | field, 32 | @resources 33 | ) %> 34 | <% label = Resource.render(@record, field, @resource, assoc_resource, @session) %> 35 |
<%= trans(humanize(field)) %>
36 |
37 | <%= if assoc_resource && Map.fetch!(@record, field) do %> 38 | <.link 39 | class="field__assoc--link" 40 | target="_blank" 41 | navigate={ 42 | route_with_params(assigns, 43 | resource_path: elem(assoc_resource, 0), 44 | segments: [Map.fetch!(@record, field)] 45 | ) 46 | } 47 | > 48 | <%= label %> 49 | 50 | <% else %> 51 | <%= label %> 52 | <% end %> 53 |
54 | <% end %> 55 |
56 |
57 | <%= if @resource.__live_admin_config__(:delete_with) != false do %> 58 | <.link 59 | navigate={route_with_params(assigns, segments: [:edit, @record])} 60 | class="resource__action--btn" 61 | > 62 | <%= trans("Edit") %> 63 | 64 | <% end %> 65 | <%= if @resource.__live_admin_config__(:delete_with) != false do %> 66 | 75 | <% end %> 76 | <.dropdown 77 | :let={action} 78 | orientation={:up} 79 | label={trans("Run action")} 80 | items={@resource.__live_admin_config__(:actions)} 81 | disabled={Enum.empty?(@resource.__live_admin_config__(:actions))} 82 | > 83 | 91 | 92 |
93 |
94 |
95 | """ 96 | end 97 | 98 | @impl true 99 | def handle_event( 100 | "delete", 101 | %{"id" => id}, 102 | %{ 103 | assigns: %{ 104 | resource: resource, 105 | session: session 106 | } 107 | } = socket 108 | ) do 109 | socket = 110 | id 111 | |> Resource.find!(resource, socket.assigns.prefix, socket.assigns.repo) 112 | |> Resource.delete(resource, session, socket.assigns.repo) 113 | |> case do 114 | {:ok, record} -> 115 | socket 116 | |> put_flash( 117 | :info, 118 | trans("Deleted %{label}", inter: [label: record_label(record, resource)]) 119 | ) 120 | |> push_navigate(to: route_with_params(socket.assigns)) 121 | 122 | {:error, _} -> 123 | push_event(socket, "error", %{ 124 | msg: trans("Delete failed!") 125 | }) 126 | end 127 | 128 | {:noreply, socket} 129 | end 130 | 131 | @impl true 132 | def handle_event( 133 | "action", 134 | params = %{"action" => action}, 135 | socket = %{assigns: %{resource: resource, prefix: prefix, repo: repo}} 136 | ) do 137 | record = socket.assigns[:record] || Resource.find!(params["id"], resource, prefix, repo) 138 | 139 | action_name = String.to_existing_atom(action) 140 | 141 | {m, f, a} = 142 | :actions 143 | |> resource.__live_admin_config__() 144 | |> Enum.find_value(fn 145 | {^action_name, mfa} -> mfa 146 | ^action_name -> {resource, action_name, []} 147 | _ -> false 148 | end) 149 | 150 | socket = 151 | case apply(m, f, [record, socket.assigns.session] ++ a) do 152 | {:ok, record} -> 153 | socket 154 | |> push_event("success", %{ 155 | msg: trans("%{action} succeeded", inter: [action: action]) 156 | }) 157 | |> assign(:record, record) 158 | 159 | {:error, error} -> 160 | push_event( 161 | socket, 162 | "error", 163 | trans("%{action} failed: %{error}", 164 | inter: [ 165 | action: action, 166 | error: error 167 | ] 168 | ) 169 | ) 170 | end 171 | 172 | {:noreply, socket} 173 | end 174 | end 175 | -------------------------------------------------------------------------------- /test/live_admin/components/container_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.Components.ContainerTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Phoenix.ConnTest 5 | import Phoenix.LiveViewTest 6 | import Mox 7 | 8 | alias LiveAdminTest.{Post, Repo, User} 9 | alias LiveAdminTest.Post.Version 10 | 11 | @endpoint LiveAdminTest.Endpoint 12 | 13 | setup do 14 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(LiveAdminTest.Repo) 15 | 16 | Mox.stub_with(LiveAdminTest.MockSession, LiveAdminTest.StubSession) 17 | 18 | %{conn: build_conn()} 19 | end 20 | 21 | describe "home page" do 22 | setup %{conn: conn} do 23 | %{response: conn |> get("/") |> html_response(200)} 24 | end 25 | 26 | test "routes live view", %{response: response} do 27 | assert response =~ ~s|LiveAdmin| 28 | end 29 | 30 | test "links to resource", %{response: response} do 31 | assert response |> Floki.find("a[href='/user']") |> Enum.any?() 32 | end 33 | end 34 | 35 | describe "list resource" do 36 | setup %{conn: conn} do 37 | Repo.insert!(%User{}) 38 | {:ok, view, _html} = live(conn, "/user") 39 | %{view: view} 40 | end 41 | 42 | test "links to new form", %{view: view} do 43 | assert {_, {:live_redirect, %{to: "/user/new"}}} = 44 | view 45 | |> element("a[href='/user/new'") 46 | |> render_click() 47 | end 48 | 49 | test "deletes record", %{view: view} do 50 | view 51 | |> element("#list") 52 | |> render_hook("delete", %{ids: []}) 53 | 54 | assert_redirected(view, "/user") 55 | end 56 | 57 | test "runs configured action on selected records", %{view: view} do 58 | view 59 | |> element("#list") 60 | |> render_hook("action", %{action: "user_action", ids: []}) 61 | 62 | assert_redirected(view, "/user") 63 | end 64 | end 65 | 66 | describe "list resource with search param" do 67 | setup %{conn: conn} do 68 | Repo.insert!(%User{name: "Tom"}) 69 | {:ok, view, html} = live(conn, "/user?s=fred") 70 | %{view: view, response: html} 71 | end 72 | 73 | test "filters results", %{view: view} do 74 | assert render(view) =~ "0 total" 75 | end 76 | 77 | test "clears search", %{view: view} do 78 | view 79 | |> element("button[phx-click='search']") 80 | |> render_click() 81 | 82 | assert render(view) =~ "1 total" 83 | end 84 | end 85 | 86 | describe "list resource with prefix param" do 87 | setup %{conn: conn} do 88 | Repo.insert!(%User{name: "Tom"}, prefix: "alt") 89 | {:ok, view, _} = live(conn, "/user?prefix=alt") 90 | %{view: view} 91 | end 92 | 93 | test "renders result from prefix", %{view: view} do 94 | assert render(view) =~ "1 total" 95 | end 96 | end 97 | 98 | describe "list resource with prefix in session" do 99 | setup %{conn: conn} do 100 | Repo.insert!(%User{name: "Tom"}, prefix: "alt") 101 | 102 | stub(LiveAdminTest.MockSession, :load!, fn _customer_id -> 103 | %LiveAdmin.Session{prefix: "alt"} 104 | end) 105 | 106 | {:ok, view, _} = live(conn, "/user") 107 | %{view: view} 108 | 109 | %{view: view} 110 | end 111 | 112 | test "renders results from prefix", %{view: view} do 113 | assert render(view) =~ "1 total" 114 | end 115 | end 116 | 117 | describe "new parent resource" do 118 | setup %{conn: conn} do 119 | {:ok, view, html} = live(conn, "/user/new") 120 | %{response: html, view: view} 121 | end 122 | 123 | test "includes castable form field", %{response: response} do 124 | assert response 125 | |> Floki.find("textarea[name='params[name]']") 126 | |> Enum.any?() 127 | end 128 | 129 | test "handles form change", %{view: view} do 130 | assert view 131 | |> element(".resource__form") 132 | |> render_change() 133 | end 134 | 135 | test "persists all form changes", %{view: view} do 136 | response = 137 | view 138 | |> element(".resource__form") 139 | |> render_change(%{ 140 | "params" => %{"name" => "test name", "settings" => %{"some_option" => "test option"}} 141 | }) 142 | 143 | assert response =~ "test option" 144 | assert response =~ "test name" 145 | end 146 | 147 | test "creates user on form submit", %{view: view} do 148 | view 149 | |> form(".resource__form", %{params: %{name: "test"}}) 150 | |> render_submit() 151 | 152 | assert [%{}] = Repo.all(User) 153 | end 154 | end 155 | 156 | describe "new child resource" do 157 | setup %{conn: conn} do 158 | {:ok, view, html} = live(conn, "/live_admin_test_post/new") 159 | %{response: html, view: view} 160 | end 161 | 162 | test "includes search select field", %{response: response} do 163 | assert response 164 | |> Floki.find("#params_user_id_search_select") 165 | |> Enum.any?() 166 | end 167 | 168 | test "search select responds to focus", %{view: view} do 169 | view 170 | |> element("#params_user_id_search_select") 171 | |> render_focus(%{value: "xxx"}) 172 | end 173 | end 174 | 175 | describe "edit resource" do 176 | setup %{conn: conn} do 177 | user = Repo.insert!(%User{}) 178 | {:ok, view, html} = live(conn, "/user/edit/#{user.id}") 179 | %{response: html, view: view, user: user} 180 | end 181 | 182 | test "updates record on submit", %{view: view, user: user} do 183 | view 184 | |> form(".resource__form", %{params: %{name: "test"}}) 185 | |> render_submit() 186 | 187 | assert %{name: "test"} = Repo.get!(User, user.id) 188 | end 189 | 190 | test "disables immutable fields", %{response: response} do 191 | assert ["disabled"] == 192 | response 193 | |> Floki.find("textarea[name='params[encrypted_password]']") 194 | |> Floki.attribute("disabled") 195 | end 196 | end 197 | 198 | describe "edit resource with embed" do 199 | setup %{conn: conn} do 200 | user = Repo.insert!(%User{settings: %{}}) 201 | {:ok, view, html} = live(conn, "/user/edit/#{user.id}") 202 | %{response: html, view: view} 203 | end 204 | 205 | test "includes embed form field", %{response: response} do 206 | assert response 207 | |> Floki.find("textarea[name='params[settings][some_option]']") 208 | |> Enum.any?() 209 | end 210 | end 211 | 212 | describe "edit resource with plural embed with multiple entries" do 213 | setup %{conn: conn} do 214 | post = Repo.insert!(%Post{title: "test", previous_versions: [%Version{}, %Version{}]}) 215 | {:ok, view, html} = live(conn, "/live_admin_test_post/edit/#{post.id}") 216 | %{response: html, view: view} 217 | end 218 | 219 | test "includes multiple embed fields", %{response: response} do 220 | assert 2 = 221 | response 222 | |> Floki.find(".embed__item") 223 | |> Enum.count() 224 | end 225 | end 226 | 227 | describe "edit resource with map field with map value" do 228 | setup %{conn: conn} do 229 | user = 230 | Repo.insert!(%User{ 231 | settings: %{metadata: %{map_key: %{}}} 232 | }) 233 | 234 | {:ok, view, html} = live(conn, "/user/edit/#{user.id}") 235 | %{response: html, view: view} 236 | end 237 | 238 | test "disables field", %{response: response} do 239 | assert response 240 | |> Floki.find(".resource__action--disabled") 241 | |> Enum.any?() 242 | end 243 | end 244 | 245 | describe "view resource" do 246 | setup %{conn: conn} do 247 | user = Repo.insert!(%User{}) 248 | {:ok, view, html} = live(conn, "/user/#{user.id}") 249 | %{response: html, view: view, user: user} 250 | end 251 | 252 | test "deletes record", %{view: view} do 253 | view 254 | |> element("button", "Delete") 255 | |> render_click() 256 | 257 | assert_redirected(view, "/user") 258 | end 259 | end 260 | 261 | describe "view resource with associated resource" do 262 | setup %{conn: conn} do 263 | user = Repo.insert!(%User{}) 264 | post = Repo.insert!(%Post{title: "test", user: user}) 265 | {:ok, _, html} = live(conn, "/live_admin_test_post/#{post.id}") 266 | %{response: html, user: user} 267 | end 268 | 269 | test "links to user", %{response: response, user: user} do 270 | assert response 271 | |> Floki.find("a[href='/user/#{user.id}']") 272 | |> Enum.any?() 273 | end 274 | end 275 | end 276 | -------------------------------------------------------------------------------- /lib/live_admin/components/container.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.Components.Container do 2 | use Phoenix.LiveView 3 | use Phoenix.HTML 4 | 5 | import LiveAdmin, 6 | only: [ 7 | resource_title: 1, 8 | route_with_params: 1, 9 | route_with_params: 2, 10 | trans: 1 11 | ] 12 | 13 | import LiveAdmin.Components 14 | 15 | alias LiveAdmin.Resource 16 | alias Phoenix.LiveView.JS 17 | 18 | @impl true 19 | def mount(_params, %{"components" => components}, socket) do 20 | socket = 21 | assign(socket, 22 | default_mod: Map.fetch!(components, socket.assigns.live_action), 23 | loading: !connected?(socket) 24 | ) 25 | 26 | Process.send_after(self(), :clear_flash, 2000) 27 | 28 | {:ok, socket} 29 | end 30 | 31 | @impl true 32 | def handle_info(:clear_flash, socket) do 33 | {:noreply, clear_flash(socket)} 34 | end 35 | 36 | @impl true 37 | def handle_params( 38 | params = %{"record_id" => id}, 39 | uri, 40 | socket = %{assigns: %{live_action: action, loading: false}} 41 | ) 42 | when action in [:edit, :view] do 43 | socket = 44 | socket 45 | |> assign_resource_info(uri) 46 | |> assign_mod() 47 | |> assign_repo() 48 | |> assign_prefix(params) 49 | 50 | record = 51 | Resource.find(id, socket.assigns.resource, socket.assigns.prefix, socket.assigns.repo) 52 | 53 | socket = assign(socket, record: record) 54 | 55 | {:noreply, socket} 56 | end 57 | 58 | @impl true 59 | def handle_params(params, uri, socket = %{assigns: %{live_action: :list, loading: false}}) do 60 | socket = 61 | socket 62 | |> assign(page: String.to_integer(params["page"] || "1")) 63 | |> assign(sort_attr: String.to_existing_atom(params["sort-attr"] || "id")) 64 | |> assign(sort_dir: String.to_existing_atom(params["sort-dir"] || "asc")) 65 | |> assign(search: params["s"]) 66 | |> assign_resource_info(uri) 67 | |> assign_mod() 68 | |> assign_repo() 69 | |> assign_prefix(params) 70 | 71 | {:noreply, socket} 72 | end 73 | 74 | @impl true 75 | def handle_params(params, uri, socket = %{assigns: %{live_action: :new}}), 76 | do: 77 | {:noreply, 78 | socket 79 | |> assign_resource_info(uri) 80 | |> assign_mod() 81 | |> assign_repo() 82 | |> assign_prefix(params)} 83 | 84 | def handle_params(_, _, socket), do: {:noreply, socket} 85 | 86 | @impl true 87 | def handle_event("set_locale", %{"locale" => locale}, socket) do 88 | new_session = Map.put(socket.assigns.session, :locale, locale) 89 | 90 | LiveAdmin.session_store().persist!(new_session) 91 | 92 | {:noreply, assign(socket, :session, new_session)} 93 | end 94 | 95 | def render(assigns = %{loading: true}), do: ~H" 96 | " 97 | 98 | @impl true 99 | def render(assigns) do 100 | ~H""" 101 |
102 |

103 | <%= resource_title(@resource) %> 104 |

105 | 106 |
107 |
108 | <.link 109 | navigate={route_with_params(assigns, params: [prefix: @prefix])} 110 | class="resource__action--btn" 111 | > 112 | <%= trans("List") %> 113 | 114 | <%= if @resource.__live_admin_config__(:create_with) != false do %> 115 | <.link 116 | navigate={route_with_params(assigns, segments: ["new"], params: [prefix: @prefix])} 117 | class="resource__action--btn" 118 | > 119 | <%= trans("New") %> 120 | 121 | <% else %> 122 | 125 | <% end %> 126 | <.dropdown 127 | :let={task} 128 | label={trans("Run task")} 129 | items={get_task_keys(@resource)} 130 | disabled={@resource |> get_task_keys() |> Enum.empty?()} 131 | > 132 | 140 | 141 | <%= if Enum.any?(@prefix_options) do %> 142 | <.dropdown 143 | :let={prefix} 144 | id="prefix-select" 145 | label={@prefix || trans("Set prefix")} 146 | items={[nil] ++ Enum.filter(@prefix_options, &(to_string(&1) != @prefix))} 147 | > 148 | <.link navigate={route_with_params(assigns, params: [prefix: prefix])}> 149 | <%= prefix || trans("clear") %> 150 | 151 | 152 | <% end %> 153 | <%= if LiveAdmin.use_i18n? do %> 154 | <.dropdown 155 | :let={locale} 156 | id="locale-select" 157 | label={@session.locale || "Set locale"} 158 | items={ 159 | Enum.filter( 160 | LiveAdmin.gettext_backend().locales(), 161 | &(to_string(&1) != @session.locale) 162 | ) 163 | } 164 | > 165 | 171 | 172 | <% end %> 173 |
174 |
175 |
176 | 177 | <%= render("#{@live_action}.html", assigns) %> 178 | """ 179 | end 180 | 181 | def render("list.html", assigns) do 182 | ~H""" 183 | <.live_component 184 | module={@mod} 185 | id="list" 186 | key={@key} 187 | resource={@resource} 188 | page={@page} 189 | sort_attr={@sort_attr} 190 | sort_dir={@sort_dir} 191 | search={@search} 192 | prefix={@prefix} 193 | session={@session} 194 | base_path={@base_path} 195 | resources={@resources} 196 | repo={@repo} 197 | /> 198 | """ 199 | end 200 | 201 | def render("new.html", assigns) do 202 | ~H""" 203 | <.live_component 204 | module={@mod} 205 | id="form" 206 | action="create" 207 | session={@session} 208 | key={@key} 209 | resources={@resources} 210 | resource={@resource} 211 | prefix={@prefix} 212 | base_path={@base_path} 213 | repo={@repo} 214 | /> 215 | """ 216 | end 217 | 218 | def render("edit.html", assigns) do 219 | ~H""" 220 | <.live_component 221 | module={@mod} 222 | id="form" 223 | action="update" 224 | session={@session} 225 | key={@key} 226 | record={@record} 227 | resources={@resources} 228 | resource={@resource} 229 | prefix={@prefix} 230 | repo={@repo} 231 | base_path={@base_path} 232 | /> 233 | """ 234 | end 235 | 236 | def render("view.html", assigns) do 237 | ~H""" 238 | <.live_component 239 | module={@mod} 240 | id="view" 241 | record={@record} 242 | resource={@resource} 243 | resources={@resources} 244 | session={@session} 245 | key={@key} 246 | base_path={@base_path} 247 | prefix={@prefix} 248 | repo={@repo} 249 | /> 250 | """ 251 | end 252 | 253 | defp assign_prefix(socket, %{"prefix" => ""}) do 254 | socket 255 | |> assign_and_presist_prefix(nil) 256 | |> push_redirect(to: route_with_params(socket.assigns)) 257 | end 258 | 259 | defp assign_prefix(socket, %{"prefix" => prefix}) do 260 | socket.assigns.prefix_options 261 | |> Enum.find(fn option -> to_string(option) == prefix end) 262 | |> case do 263 | nil -> 264 | push_redirect(socket, to: route_with_params(socket.assigns)) 265 | 266 | prefix -> 267 | assign_and_presist_prefix(socket, prefix) 268 | end 269 | end 270 | 271 | defp assign_prefix(socket = %{assigns: %{session: session}}, _) do 272 | case session.prefix do 273 | nil -> 274 | assign_and_presist_prefix(socket, nil) 275 | 276 | prefix -> 277 | push_patch(socket, to: route_with_params(socket.assigns, params: [prefix: prefix])) 278 | end 279 | end 280 | 281 | defp assign_and_presist_prefix(socket, prefix) do 282 | new_session = Map.put(socket.assigns.session, :prefix, prefix) 283 | 284 | LiveAdmin.session_store().persist!(new_session) 285 | 286 | assign(socket, prefix: prefix, session: new_session) 287 | end 288 | 289 | defp get_task_keys(resource) do 290 | :tasks 291 | |> resource.__live_admin_config__() 292 | |> Enum.map(fn 293 | {key, _} -> key 294 | key -> key 295 | end) 296 | end 297 | 298 | defp assign_resource_info(socket, uri) do 299 | %URI{host: host, path: path} = URI.parse(uri) 300 | 301 | %{resource: {key, mod}} = Phoenix.Router.route_info(socket.router, "GET", path, host) 302 | 303 | assign(socket, key: key, resource: mod) 304 | end 305 | 306 | defp assign_mod( 307 | socket = %{assigns: %{resource: resource, live_action: action, default_mod: default}} 308 | ) do 309 | mod = 310 | :components 311 | |> resource.__live_admin_config__() 312 | |> Keyword.get(action, default) 313 | 314 | assign(socket, :mod, mod) 315 | end 316 | 317 | defp assign_repo(socket = %{assigns: %{resource: resource, default_repo: default}}) do 318 | repo = 319 | :ecto_repo 320 | |> resource.__live_admin_config__() 321 | |> Kernel.||(default) 322 | |> Kernel.||(raise "no repo configured") 323 | 324 | prefix_options = 325 | if function_exported?(repo, :prefixes, 0) do 326 | repo.prefixes() 327 | else 328 | [] 329 | end 330 | 331 | assign(socket, repo: repo, prefix_options: prefix_options) 332 | end 333 | end 334 | -------------------------------------------------------------------------------- /lib/live_admin/components/resource/form.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.Components.Container.Form do 2 | use Phoenix.LiveComponent 3 | use Phoenix.HTML 4 | 5 | import LiveAdmin.Components 6 | import LiveAdmin.ErrorHelpers 7 | import LiveAdmin, only: [associated_resource: 4, route_with_params: 2, trans: 1] 8 | import LiveAdmin.View, only: [supported_type?: 1, field_class: 1] 9 | 10 | alias __MODULE__.{ArrayInput, MapInput, SearchSelect} 11 | alias LiveAdmin.Resource 12 | 13 | @impl true 14 | def update(assigns = %{record: record}, socket) do 15 | socket = 16 | socket 17 | |> assign(assigns) 18 | |> assign(:enabled, false) 19 | |> assign(:changeset, Resource.change(assigns.resource, record)) 20 | 21 | {:ok, socket} 22 | end 23 | 24 | @impl true 25 | def update(assigns, socket) do 26 | socket = 27 | socket 28 | |> assign(assigns) 29 | |> assign(:enabled, false) 30 | |> assign(:changeset, Resource.change(assigns.resource)) 31 | 32 | {:ok, socket} 33 | end 34 | 35 | @impl true 36 | def render(assigns = %{record: nil}) do 37 | ~H""" 38 |
<%= trans("No record found") %>
39 | """ 40 | end 41 | 42 | @impl true 43 | def render(assigns) do 44 | ~H""" 45 |
46 | <.form 47 | :let={f} 48 | for={@changeset} 49 | as={:params} 50 | phx-change="validate" 51 | phx-submit={@action} 52 | phx-target={@myself} 53 | class="resource__form" 54 | > 55 | <%= for {field, type, opts} <- Resource.fields(@resource) do %> 56 | <.field 57 | field={field} 58 | type={type} 59 | form={f} 60 | immutable={Keyword.get(opts, :immutable, false)} 61 | resource={@resource} 62 | resources={@resources} 63 | session={@session} 64 | prefix={@prefix} 65 | repo={@repo} 66 | /> 67 | <% end %> 68 |
69 | <%= if assigns[:record] do %> 70 | 74 | <%= trans("Cancel") %> 75 | 76 | <% end %> 77 | <%= submit(trans("Save"), 78 | class: "resource__action#{if !@enabled, do: "--disabled", else: "--btn"}", 79 | disabled: !@enabled 80 | ) %> 81 |
82 | 83 |
84 | """ 85 | end 86 | 87 | @impl true 88 | def handle_event( 89 | "validate", 90 | %{"params" => params}, 91 | socket = %{assigns: %{resource: resource, changeset: changeset, session: session}} 92 | ) do 93 | changeset = validate(resource, changeset, params, session) 94 | 95 | {:noreply, 96 | assign(socket, 97 | changeset: changeset, 98 | enabled: enabled?(changeset, socket.assigns.action, resource) 99 | )} 100 | end 101 | 102 | @impl true 103 | def handle_event( 104 | "create", 105 | %{"params" => params}, 106 | %{assigns: %{resource: resource, session: session, repo: repo}} = socket 107 | ) do 108 | socket = 109 | case Resource.create(resource, params, session, repo) do 110 | {:ok, _} -> 111 | socket 112 | |> put_flash(:info, trans("Record successfully added")) 113 | |> push_redirect( 114 | to: route_with_params(socket.assigns, params: [prefix: socket.assigns.prefix]) 115 | ) 116 | 117 | {:error, changeset} -> 118 | assign(socket, changeset: changeset) 119 | end 120 | 121 | {:noreply, socket} 122 | end 123 | 124 | @impl true 125 | def handle_event( 126 | "update", 127 | %{"params" => params}, 128 | %{assigns: %{resource: resource, session: session, record: record}} = socket 129 | ) do 130 | socket = 131 | Resource.update(record, resource, params, session) 132 | |> case do 133 | {:ok, _} -> 134 | socket 135 | |> put_flash(:info, trans("Record successfully updated")) 136 | |> push_redirect(to: route_with_params(socket.assigns, segments: [record])) 137 | 138 | {:error, changeset} -> 139 | assign(socket, changeset: changeset) 140 | end 141 | 142 | {:noreply, socket} 143 | end 144 | 145 | def field(assigns) do 146 | ~H""" 147 |
148 | <%= label(@form, @field, @field |> humanize() |> trans(), class: "field__label") %> 149 | <%= if supported_type?(@type) do %> 150 | <.input 151 | form={@form} 152 | type={@type} 153 | field={@field} 154 | disabled={@immutable} 155 | resource={@resource} 156 | resources={@resources} 157 | session={@session} 158 | prefix={@prefix} 159 | repo={@repo} 160 | /> 161 | <% else %> 162 | <%= textarea(@form, @field, 163 | rows: 1, 164 | disabled: true, 165 | value: @form |> input_value(@field) |> inspect() 166 | ) %> 167 | <% end %> 168 | <%= error_tag(@form, @field) %> 169 |
170 | """ 171 | end 172 | 173 | defp input(assigns = %{type: {_, Ecto.Embedded, _}}) do 174 | ~H""" 175 | <.embed 176 | id={input_id(@form, @field)} 177 | type={@type} 178 | disabled={@disabled} 179 | form={@form} 180 | field={@field} 181 | resource={@resource} 182 | resources={@resource} 183 | session={@session} 184 | prefix={@prefix} 185 | repo={@repo} 186 | /> 187 | """ 188 | end 189 | 190 | defp input(assigns = %{type: id}) when id in [:id, :binary_id] do 191 | assigns = 192 | assign( 193 | assigns, 194 | :associated_resource, 195 | associated_resource( 196 | assigns.resource.__live_admin_config__(:schema), 197 | assigns.field, 198 | assigns.resources, 199 | :resource 200 | ) 201 | ) 202 | 203 | ~H""" 204 | <%= if @associated_resource do %> 205 | <%= unless @form.data |> Ecto.primary_key() |> Keyword.keys() |> Enum.member?(@field) do %> 206 | <.live_component 207 | module={SearchSelect} 208 | id={input_id(@form, @field)} 209 | form={@form} 210 | field={@field} 211 | disabled={@disabled} 212 | resource={@associated_resource} 213 | session={@session} 214 | prefix={@prefix} 215 | repo={@repo} 216 | /> 217 | <% else %> 218 |
219 | <%= number_input(@form, @field, disabled: @disabled) %> 220 |
221 | <% end %> 222 | <% else %> 223 | <%= textarea(@form, @field, rows: 1, disabled: @disabled) %> 224 | <% end %> 225 | """ 226 | end 227 | 228 | defp input(assigns = %{type: {:array, :string}}) do 229 | ~H""" 230 | <.live_component 231 | module={ArrayInput} 232 | id={input_id(@form, @field)} 233 | form={@form} 234 | field={@field} 235 | disabled={@disabled} 236 | /> 237 | """ 238 | end 239 | 240 | defp input(assigns = %{type: :map}) do 241 | ~H""" 242 | <.live_component 243 | module={MapInput} 244 | id={input_id(@form, @field)} 245 | form={@form} 246 | field={@field} 247 | disabled={@disabled} 248 | /> 249 | """ 250 | end 251 | 252 | defp input(assigns = %{type: :string}) do 253 | ~H""" 254 | <%= textarea(@form, @field, rows: 1, disabled: @disabled, phx_debounce: 200) %> 255 | """ 256 | end 257 | 258 | defp input(assigns = %{type: :boolean}) do 259 | ~H""" 260 |
261 | <%= for option <- ["true", "false"] do %> 262 | <%= radio_button(@form, @field, option) %> 263 | <%= trans(option) %> 264 | <% end %> 265 | <%= radio_button(@form, @field, "", checked: input_value(@form, @field) in ["", nil]) %> 266 | <%= trans("nil") %> 267 |
268 | """ 269 | end 270 | 271 | defp input(assigns = %{type: :date}) do 272 | ~H""" 273 | <%= date_input(@form, @field, disabled: @disabled) %> 274 | """ 275 | end 276 | 277 | defp input(assigns = %{type: number}) when number in [:integer, :id] do 278 | ~H""" 279 |
280 | <%= number_input(@form, @field, disabled: @disabled, phx_debounce: 200) %> 281 |
282 | """ 283 | end 284 | 285 | defp input(assigns = %{type: :float}) do 286 | ~H""" 287 |
288 | <%= number_input(@form, @field, disabled: @disabled, step: "any", phx_debounce: 200) %> 289 |
290 | """ 291 | end 292 | 293 | defp input(assigns = %{type: type}) when type in [:naive_datetime, :utc_datetime] do 294 | ~H""" 295 |
296 | <%= datetime_local_input(@form, @field, disabled: @disabled) %> 297 |
298 | """ 299 | end 300 | 301 | defp input(assigns = %{type: {_, Ecto.Enum, %{mappings: mappings}}}) do 302 | assigns = assign(assigns, :mappings, mappings) 303 | 304 | ~H""" 305 | <%= select(@form, @field, [nil | Keyword.keys(@mappings)], disabled: @disabled) %> 306 | """ 307 | end 308 | 309 | defp input(assigns = %{type: {:array, {_, Ecto.Enum, %{mappings: mappings}}}}) do 310 | assigns = assign(assigns, :mappings, mappings) 311 | 312 | ~H""" 313 |
314 | <%= hidden_input(@form, @field, name: input_name(@form, @field) <> "[]", value: nil) %> 315 | <%= for option <- Keyword.keys(@mappings) do %> 316 | <%= checkbox(@form, @field, 317 | name: input_name(@form, @field) <> "[]", 318 | checked_value: option, 319 | value: 320 | @form 321 | |> input_value(@field) 322 | |> Kernel.||([]) 323 | |> Enum.find(&(to_string(&1) == to_string(option))), 324 | unchecked_value: "", 325 | hidden_input: false, 326 | disabled: @disabled, 327 | id: input_id(@form, @field) <> to_string(option) 328 | ) %> 329 | 332 | <% end %> 333 |
334 | """ 335 | end 336 | 337 | defp validate(resource, changeset, params, session) do 338 | resource 339 | |> Resource.change(changeset.data, params) 340 | |> Resource.validate(resource, session) 341 | end 342 | 343 | def enabled?(changeset, action, resource) do 344 | resource.__live_admin_config__(:"#{action}_with") != false && Enum.empty?(changeset.errors) 345 | end 346 | end 347 | -------------------------------------------------------------------------------- /lib/live_admin/resource.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.Resource do 2 | @moduledoc """ 3 | API for managing Ecto schemas and their individual record instances used internally by LiveAdmin. 4 | 5 | > #### `use LiveAdmin.Resource` {: .info} 6 | > This is required in any module that should act as a LiveAdmin Resource. 7 | > If the module is not an Ecto schema, then the `:schema` option must be passed. 8 | > Using this module will create a __live_admin_config__ module variable and 2 functions 9 | > to query it, __live_admin_config__/0 and __live_admin_config__/1. The former returns the entire 10 | > config while the latter will return a key if it exists, otherwise it will fallback 11 | > to either a global config for that key, or the key's default value. 12 | 13 | To customize UI behavior, the following options may also be used: 14 | 15 | * `title_with` - a binary, or MFA that returns a binary, used to identify the resource 16 | * `label_with` - a binary, or MFA that returns a binary, used to identify records 17 | * `list_with` - an atom or MFA that identifies the function that implements listing the resource 18 | * `create_with` - an atom or MFA that identifies the function that implements creating the resource (set to false to disable create) 19 | * `update_with` - an atom or MFA that identifies the function that implements updating a record (set to false to disable update) 20 | * `delete_with` - an atom or MFA that identifies the function that implements deleting a record (set to false to disable delete) 21 | * `validate_with` - an atom or MFA that identifies the function that implements validating a changed record 22 | * `render_with` - an atom or MFA that identifies the function that implements table field rendering logic 23 | * `hidden_fields` - a list of fields that should not be displayed in the UI 24 | * `immutable_fields` - a list of fields that should not be editable in forms 25 | * `actions` - list of atoms or MFAs that identify a function that operates on a record 26 | * `tasks` - list atoms or MFAs that identify a function that operates on a resource 27 | * `components` - keyword list of component module overrides for specific views (`:list`, `:new`, `:edit`, `:home`, `:nav`, `:session`, `:view`) 28 | * `ecto_repo` - Ecto repo to use when building queries for this resource 29 | """ 30 | 31 | import Ecto.Query 32 | import LiveAdmin, only: [record_label: 2, parent_associations: 1] 33 | 34 | alias Ecto.Changeset 35 | 36 | @doc false 37 | defmacro __using__(opts) do 38 | quote bind_quoted: [opts: opts] do 39 | @__live_admin_config__ Keyword.put_new(opts, :schema, __MODULE__) 40 | 41 | def __live_admin_config__, do: @__live_admin_config__ 42 | 43 | def __live_admin_config__(key) do 44 | @__live_admin_config__ 45 | |> Keyword.get(key, Application.get_env(:live_admin, key)) 46 | |> case do 47 | false -> false 48 | nil -> LiveAdmin.Resource.default_config_value(key) 49 | config -> config 50 | end 51 | end 52 | end 53 | end 54 | 55 | def render(record, field, resource, assoc_resource, session) do 56 | :render_with 57 | |> resource.__live_admin_config__() 58 | |> case do 59 | nil -> 60 | if assoc_resource do 61 | record_label( 62 | Map.fetch!(record, get_assoc_name!(resource.__live_admin_config__(:schema), field)), 63 | elem(assoc_resource, 1) 64 | ) 65 | else 66 | record 67 | |> Map.fetch!(field) 68 | |> render_field() 69 | end 70 | 71 | {m, f, a} -> 72 | apply(m, f, [record, field, session] ++ a) 73 | 74 | f when is_atom(f) -> 75 | apply(resource, f, [record, field, session]) 76 | end 77 | end 78 | 79 | def all(ids, resource, prefix, repo) do 80 | resource.__live_admin_config__(:schema) 81 | |> where([s], s.id in ^ids) 82 | |> repo.all(prefix: prefix) 83 | end 84 | 85 | def find!(id, resource, prefix, repo) do 86 | find(id, resource, prefix, repo) || 87 | raise(Ecto.NoResultsError, queryable: resource.__live_admin_config__(:schema)) 88 | end 89 | 90 | def find(id, resource, prefix, repo) do 91 | resource.__live_admin_config__(:schema) 92 | |> preload(^preloads(resource)) 93 | |> repo.get(id, prefix: prefix) 94 | end 95 | 96 | def delete(record, resource, session, repo) do 97 | :delete_with 98 | |> resource.__live_admin_config__() 99 | |> case do 100 | nil -> 101 | repo.delete(record) 102 | 103 | {mod, func_name, args} -> 104 | apply(mod, func_name, [record, session] ++ args) 105 | 106 | name when is_atom(name) -> 107 | apply(resource, name, [record, session]) 108 | end 109 | end 110 | 111 | def list(resource, opts, session, repo) do 112 | :list_with 113 | |> resource.__live_admin_config__() 114 | |> case do 115 | nil -> 116 | build_list(resource, opts, repo) 117 | 118 | {mod, func_name, args} -> 119 | apply(mod, func_name, [resource, opts, session] ++ args) 120 | 121 | name when is_atom(name) -> 122 | apply(resource, name, [opts, session]) 123 | end 124 | end 125 | 126 | def change(resource, record \\ nil, params \\ %{}) 127 | 128 | def change(resource, record, params) when is_struct(record) do 129 | build_changeset(record, resource, params) 130 | end 131 | 132 | def change(resource, nil, params) do 133 | :schema 134 | |> resource.__live_admin_config__() 135 | |> struct(%{}) 136 | |> build_changeset(resource, params) 137 | end 138 | 139 | def create(resource, params, session, repo) do 140 | :create_with 141 | |> resource.__live_admin_config__() 142 | |> case do 143 | nil -> 144 | resource 145 | |> change(nil, params) 146 | |> repo.insert(prefix: session.prefix) 147 | 148 | {mod, func_name, args} -> 149 | apply(mod, func_name, [params, session] ++ args) 150 | 151 | name when is_atom(name) -> 152 | apply(resource, name, [params, session]) 153 | end 154 | end 155 | 156 | def update(record, resource, params, session) do 157 | :update_with 158 | |> resource.__live_admin_config__() 159 | |> case do 160 | nil -> 161 | resource 162 | |> change(record, params) 163 | |> resource.__live_admin_config__(:ecto_repo).update() 164 | 165 | {mod, func_name, args} -> 166 | apply(mod, func_name, [record, params, session] ++ args) 167 | 168 | name when is_atom(name) -> 169 | apply(resource, name, [record, params, session]) 170 | end 171 | end 172 | 173 | def validate(changeset, resource, session) do 174 | :validate_with 175 | |> resource.__live_admin_config__() 176 | |> case do 177 | nil -> changeset 178 | {mod, func_name, args} -> apply(mod, func_name, [changeset, session] ++ args) 179 | name when is_atom(name) -> apply(resource, name, [changeset, session]) 180 | end 181 | |> Map.put(:action, :validate) 182 | end 183 | 184 | def fields(resource) do 185 | schema = resource.__live_admin_config__(:schema) 186 | 187 | Enum.flat_map(schema.__schema__(:fields), fn field_name -> 188 | :hidden_fields 189 | |> resource.__live_admin_config__() 190 | |> Enum.member?(field_name) 191 | |> case do 192 | false -> 193 | [ 194 | {field_name, schema.__schema__(:type, field_name), 195 | [ 196 | immutable: 197 | Enum.member?(resource.__live_admin_config__(:immutable_fields) || [], field_name) 198 | ]} 199 | ] 200 | 201 | true -> 202 | [] 203 | end 204 | end) 205 | end 206 | 207 | def default_config_value(key) when key in [:actions, :tasks, :components, :hidden_fields], 208 | do: [] 209 | 210 | def default_config_value(:label_with), do: :id 211 | 212 | def default_config_value(_), do: nil 213 | 214 | defp build_list(resource, opts, repo) do 215 | opts = 216 | opts 217 | |> Enum.into(%{}) 218 | |> Map.put_new(:page, 1) 219 | |> Map.put_new(:sort_dir, :asc) 220 | |> Map.put_new(:sort_attr, :id) 221 | 222 | query = 223 | :schema 224 | |> resource.__live_admin_config__() 225 | |> limit(10) 226 | |> offset(^((opts[:page] - 1) * 10)) 227 | |> order_by(^[{opts[:sort_dir], opts[:sort_attr]}]) 228 | |> preload(^preloads(resource)) 229 | 230 | query = 231 | opts 232 | |> Enum.reduce(query, fn 233 | {:search, q}, query when byte_size(q) > 0 -> 234 | apply_search(query, q, fields(resource)) 235 | 236 | _, query -> 237 | query 238 | end) 239 | 240 | { 241 | repo.all(query, prefix: opts[:prefix]), 242 | repo.aggregate( 243 | query |> exclude(:limit) |> exclude(:offset), 244 | :count, 245 | prefix: opts[:prefix] 246 | ) 247 | } 248 | end 249 | 250 | defp apply_search(query, q, fields) do 251 | q 252 | |> String.split(~r{[^\s]*:}, include_captures: true, trim: true) 253 | |> case do 254 | [q] -> 255 | matcher = if String.contains?(q, "%"), do: q, else: "%#{q}%" 256 | 257 | Enum.reduce(fields, query, fn {field_name, _, _}, query -> 258 | or_where( 259 | query, 260 | [r], 261 | like( 262 | fragment("LOWER(CAST(? AS text))", field(r, ^field_name)), 263 | ^String.downcase(matcher) 264 | ) 265 | ) 266 | end) 267 | 268 | field_queries -> 269 | field_queries 270 | |> Enum.map(&String.trim/1) 271 | |> Enum.chunk_every(2) 272 | |> Enum.reduce(query, fn 273 | [field_key, q], query -> 274 | fields 275 | |> Enum.find_value(fn {field_name, _, _} -> 276 | if "#{field_name}:" == field_key, do: field_name 277 | end) 278 | |> case do 279 | nil -> 280 | query 281 | 282 | field_name -> 283 | or_where( 284 | query, 285 | [r], 286 | ilike(fragment("CAST(? AS text)", field(r, ^field_name)), ^"%#{q}%") 287 | ) 288 | end 289 | 290 | _, query -> 291 | query 292 | end) 293 | end 294 | end 295 | 296 | defp build_changeset(record = %schema{}, resource, params) do 297 | resource 298 | |> case do 299 | :embed -> 300 | Enum.map(schema.__schema__(:fields), fn field_name -> 301 | {field_name, schema.__schema__(:type, field_name), []} 302 | end) 303 | 304 | resource -> 305 | fields(resource) 306 | end 307 | |> Enum.reduce(Changeset.cast(record, params, []), fn 308 | {field_name, {_, Ecto.Embedded, %{cardinality: :many}}, _}, changeset -> 309 | Changeset.cast_embed(changeset, field_name, 310 | with: fn embed, params -> build_changeset(embed, :embed, params) end, 311 | sort_param: LiveAdmin.View.sort_param_name(field_name), 312 | drop_param: LiveAdmin.View.drop_param_name(field_name) 313 | ) 314 | 315 | {field_name, {_, Ecto.Embedded, %{cardinality: :one}}, _}, changeset -> 316 | if Map.get(params, to_string(field_name)) == "" do 317 | Changeset.put_change(changeset, field_name, nil) 318 | else 319 | Changeset.cast_embed(changeset, field_name, 320 | with: fn embed, params -> build_changeset(embed, :embed, params) end 321 | ) 322 | end 323 | 324 | {field_name, type, opts}, changeset -> 325 | unless Keyword.get(opts, :immutable, false) do 326 | changeset = Changeset.cast(changeset, params, [field_name]) 327 | 328 | if type == :map do 329 | Changeset.update_change(changeset, field_name, &parse_map_param/1) 330 | else 331 | changeset 332 | end 333 | else 334 | changeset 335 | end 336 | end) 337 | end 338 | 339 | defp parse_map_param(param = %{}) do 340 | param 341 | |> Enum.sort_by(fn {idx, _} -> idx end) 342 | |> Map.new(fn {_, %{"key" => key, "value" => value}} -> {key, value} end) 343 | end 344 | 345 | defp parse_map_param(param), do: param 346 | 347 | defp preloads(resource) do 348 | :preload 349 | |> resource.__live_admin_config__() 350 | |> case do 351 | nil -> 352 | resource.__live_admin_config__(:schema) 353 | |> parent_associations() 354 | |> Enum.map(& &1.field) 355 | 356 | {m, f, a} -> 357 | apply(m, f, [resource | a]) 358 | 359 | preloads when is_list(preloads) -> 360 | preloads 361 | end 362 | end 363 | 364 | defp get_assoc_name!(schema, fk) do 365 | Enum.find(schema.__schema__(:associations), fn assoc_name -> 366 | fk == schema.__schema__(:association, assoc_name).owner_key 367 | end) 368 | end 369 | 370 | defp render_field(val = %{}), do: Phoenix.HTML.Tag.content_tag(:pre, inspect(val, pretty: true)) 371 | defp render_field(val) when is_list(val), do: Enum.map(val, &render_field/1) 372 | defp render_field(val) when is_binary(val), do: Phoenix.HTML.Format.text_to_html(val) 373 | defp render_field(val), do: val 374 | end 375 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "castore": {:hex, :castore, "1.0.3", "7130ba6d24c8424014194676d608cb989f62ef8039efd50ff4b3f33286d06db8", [:mix], [], "hexpm", "680ab01ef5d15b161ed6a95449fac5c6b8f60055677a8e79acf01b27baa4390b"}, 3 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 4 | "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, 5 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 6 | "cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, 7 | "db_connection": {:hex, :db_connection, "2.5.0", "bb6d4f30d35ded97b29fe80d8bd6f928a1912ca1ff110831edcd238a1973652c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c92d5ba26cd69ead1ff7582dbb860adeedfff39774105a4f1c92cbb654b55aa2"}, 8 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 9 | "dialyxir": {:hex, :dialyxir, "1.2.0", "58344b3e87c2e7095304c81a9ae65cb68b613e28340690dfe1a5597fd08dec37", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "61072136427a851674cab81762be4dbeae7679f85b1272b6d25c3a839aff8463"}, 10 | "docout": {:git, "https://github.com/tfwright/docout.git", "5a0ebd0a1cbb77bc9d82b6efd2d0d97d24ebb960", [branch: "main"]}, 11 | "earmark_parser": {:hex, :earmark_parser, "1.4.20", "89970db71b11b6b89759ce16807e857df154f8df3e807b2920a8c39834a9e5cf", [:mix], [], "hexpm", "1eb0d2dabeeeff200e0d17dc3048a6045aab271f73ebb82e416464832eb57bdd"}, 12 | "ecto": {:hex, :ecto, "3.10.3", "eb2ae2eecd210b4eb8bece1217b297ad4ff824b4384c0e3fdd28aaf96edd6135", [: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 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "44bec74e2364d491d70f7e42cd0d690922659d329f6465e89feb8a34e8cd3433"}, 13 | "ecto_psql_extras": {:hex, :ecto_psql_extras, "0.7.4", "5d43fd088d39a158c860b17e8d210669587f63ec89ea122a4654861c8c6e2db4", [:mix], [{:ecto_sql, "~> 3.4", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.15.7", [hex: :postgrex, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1.1", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "311db02f1b772e3d0dc7f56a05044b5e1499d78ed6abf38885e1ca70059449e5"}, 14 | "ecto_sql": {:hex, :ecto_sql, "3.10.2", "6b98b46534b5c2f8b8b5f03f126e75e2a73c64f3c071149d32987a5378b0fdbd", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "68c018debca57cb9235e3889affdaec7a10616a4e3a80c99fa1d01fdafaa9007"}, 15 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 16 | "ex_doc": {:hex, :ex_doc, "0.28.2", "e031c7d1a9fc40959da7bf89e2dc269ddc5de631f9bd0e326cbddf7d8085a9da", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "51ee866993ffbd0e41c084a7677c570d0fc50cb85c6b5e76f8d936d9587fa719"}, 17 | "expo": {:hex, :expo, "0.4.1", "1c61d18a5df197dfda38861673d392e642649a9cef7694d2f97a587b2cfb319b", [:mix], [], "hexpm", "2ff7ba7a798c8c543c12550fa0e2cbc81b95d4974c65855d8d15ba7b37a1ce47"}, 18 | "faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"}, 19 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 20 | "floki": {:hex, :floki, "0.32.0", "f915dc15258bc997d49be1f5ef7d3992f8834d6f5695270acad17b41f5bcc8e2", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "1c5a91cae1fd8931c26a4826b5e2372c284813904c8bacb468b5de39c7ececbd"}, 21 | "gettext": {:hex, :gettext, "0.22.3", "c8273e78db4a0bb6fba7e9f0fd881112f349a3117f7f7c598fa18c66c888e524", [:mix], [{:expo, "~> 0.4.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "935f23447713954a6866f1bb28c3a878c4c011e802bcd68a726f5e558e4b64bd"}, 22 | "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, 23 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 24 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 25 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, 26 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 27 | "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, 28 | "mox": {:hex, :mox, "1.0.2", "dc2057289ac478b35760ba74165b4b3f402f68803dd5aecd3bfd19c183815d64", [:mix], [], "hexpm", "f9864921b3aaf763c8741b5b8e6f908f44566f1e427b2630e89e9a73b981fef2"}, 29 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 30 | "phoenix": {:hex, :phoenix, "1.7.7", "4cc501d4d823015007ba3cdd9c41ecaaf2ffb619d6fb283199fa8ddba89191e0", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "8966e15c395e5e37591b6ed0bd2ae7f48e961f0f60ac4c733f9566b519453085"}, 31 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.2", "b21bd01fdeffcfe2fab49e4942aa938b6d3e89e93a480d4aee58085560a0bc0d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "70242edd4601d50b69273b057ecf7b684644c19ee750989fd555625ae4ce8f5d"}, 32 | "phoenix_html": {:hex, :phoenix_html, "3.3.2", "d6ce982c6d8247d2fc0defe625255c721fb8d5f1942c5ac051f6177bffa5973f", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "44adaf8e667c1c20fb9d284b6b0fa8dc7946ce29e81ce621860aa7e96de9a11d"}, 33 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.3", "3a53772a6118d5679bf50fc1670505a290e32a1d195df9e069d8c53ab040c054", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "766796676e5f558dbae5d1bdb066849673e956005e3730dfd5affd7a6da4abac"}, 34 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.0", "3f3531c835e46a3b45b4c3ca4a09cef7ba1d0f0d0035eef751c7084b8adb1299", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "29875f8a58fb031f2dc8f3be025c92ed78d342b46f9bbf6dfe579549d7c81050"}, 35 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 36 | "phoenix_template": {:hex, :phoenix_template, "1.0.3", "32de561eefcefa951aead30a1f94f1b5f0379bc9e340bb5c667f65f1edfa4326", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "16f4b6588a4152f3cc057b9d0c0ba7e82ee23afa65543da535313ad8d25d8e2c"}, 37 | "phoenix_view": {:hex, :phoenix_view, "2.0.2", "6bd4d2fd595ef80d33b439ede6a19326b78f0f1d8d62b9a318e3d9c1af351098", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "a929e7230ea5c7ee0e149ffcf44ce7cf7f4b6d2bfe1752dd7c084cdff152d36f"}, 38 | "plug": {:hex, :plug, "1.14.2", "cff7d4ec45b4ae176a227acd94a7ab536d9b37b942c8e8fa6dfc0fff98ff4d80", [:mix], [{:mime, "~> 1.0 or ~> 2.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.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "842fc50187e13cf4ac3b253d47d9474ed6c296a8732752835ce4a86acdf68d13"}, 39 | "plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"}, 40 | "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, 41 | "postgrex": {:hex, :postgrex, "0.17.3", "c92cda8de2033a7585dae8c61b1d420a1a1322421df84da9a82a6764580c503d", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "946cf46935a4fdca7a81448be76ba3503cff082df42c6ec1ff16a4bdfbfb098d"}, 42 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 43 | "table_rex": {:hex, :table_rex, "3.1.1", "0c67164d1714b5e806d5067c1e96ff098ba7ae79413cc075973e17c38a587caa", [:mix], [], "hexpm", "678a23aba4d670419c23c17790f9dcd635a4a89022040df7d5d772cb21012490"}, 44 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 45 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 46 | "websock_adapter": {:hex, :websock_adapter, "0.5.4", "7af8408e7ed9d56578539594d1ee7d8461e2dd5c3f57b0f2a5352d610ddde757", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d2c238c79c52cbe223fcdae22ca0bb5007a735b9e933870e241fce66afb4f4ab"}, 47 | } 48 | -------------------------------------------------------------------------------- /lib/live_admin/components/resource/index.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveAdmin.Components.Container.Index do 2 | use Phoenix.LiveComponent 3 | use Phoenix.HTML 4 | 5 | import LiveAdmin, 6 | only: [ 7 | route_with_params: 1, 8 | route_with_params: 2, 9 | trans: 1, 10 | trans: 2 11 | ] 12 | 13 | import LiveAdmin.Components 14 | 15 | alias LiveAdmin.Resource 16 | alias Phoenix.LiveView.JS 17 | 18 | @impl true 19 | def update(assigns, socket) do 20 | socket = 21 | socket 22 | |> assign(assigns) 23 | |> assign( 24 | records: 25 | Resource.list( 26 | assigns.resource, 27 | Map.take(assigns, [:prefix, :sort_attr, :sort_dir, :page, :search]), 28 | assigns.session, 29 | assigns.repo 30 | ) 31 | ) 32 | 33 | {:ok, socket} 34 | end 35 | 36 | @impl true 37 | def render(assigns) do 38 | ~H""" 39 |
40 | 64 |
65 | 66 | 67 | 68 | 76 | <%= for {field, _, _} <- Resource.fields(@resource) do %> 77 | 98 | <% end %> 99 | 100 | 101 | 102 | <%= for record <- @records |> elem(0) do %> 103 | 104 | 114 | <%= for {field, type, _} <- Resource.fields(@resource) do %> 115 | <% assoc_resource = 116 | LiveAdmin.associated_resource( 117 | @resource.__live_admin_config__(:schema), 118 | field, 119 | @resources 120 | ) %> 121 | 167 | <% end %> 168 | 169 | <% end %> 170 | 171 | 172 | 173 | 211 | 214 | 215 | 216 | 246 | 247 | 248 |
69 | 75 | 78 | <.link 79 | patch={ 80 | route_with_params( 81 | assigns, 82 | params: 83 | list_link_params(assigns, 84 | sort_attr: field, 85 | sort_dir: 86 | if(field == @sort_attr, 87 | do: Enum.find([:asc, :desc], &(&1 != @sort_dir)), 88 | else: @sort_dir 89 | ) 90 | ) 91 | ) 92 | } 93 | class={"header__link#{if field == @sort_attr, do: "--#{[asc: :up, desc: :down][@sort_dir]}"}"} 94 | > 95 | <%= trans(humanize(field)) %> 96 | 97 |
105 |
106 | 112 |
113 |
122 |
123 | <%= Resource.render(record, field, @resource, assoc_resource, @session) %> 124 |
125 |
126 |
127 | 132 | 133 | 134 |
135 | <%= if record |> Ecto.primary_key() |> Keyword.keys() |> Enum.member?(field) || (assoc_resource && Map.fetch!(record, field)) do %> 136 | 149 | 157 | 162 | 163 | 164 | <% end %> 165 |
166 |
249 |
250 |
251 | """ 252 | end 253 | 254 | @impl true 255 | def handle_event("task", %{"task" => task}, socket) do 256 | task_name = String.to_existing_atom(task) 257 | 258 | {m, f, a} = 259 | :tasks 260 | |> socket.assigns.resource.__live_admin_config__() 261 | |> Enum.find_value(fn 262 | {^task_name, mfa} -> mfa 263 | ^task_name -> {socket.assigns.resource, task_name, []} 264 | end) 265 | 266 | socket = 267 | case apply(m, f, [socket.assigns.session] ++ a) do 268 | {:ok, result} -> 269 | socket 270 | |> put_flash( 271 | :info, 272 | trans("%{task} succeeded: %{result}", 273 | inter: [ 274 | task: task, 275 | result: result 276 | ] 277 | ) 278 | ) 279 | |> push_navigate(to: route_with_params(socket.assigns)) 280 | 281 | {:error, error} -> 282 | push_event(socket, "error", %{ 283 | msg: 284 | trans("%{task} failed: %{error}", 285 | inter: [ 286 | task: task, 287 | error: error 288 | ] 289 | ) 290 | }) 291 | end 292 | 293 | {:noreply, socket} 294 | end 295 | 296 | @impl true 297 | def handle_event("search", %{"query" => query}, socket = %{assigns: assigns}) do 298 | {:noreply, 299 | push_patch(socket, 300 | to: 301 | route_with_params(socket.assigns, 302 | params: 303 | assigns 304 | |> Map.take([:prefix, :sort_dir, :sort_attr, :page]) 305 | |> Map.put(:search, query) 306 | ) 307 | )} 308 | end 309 | 310 | @impl true 311 | def handle_event( 312 | "delete", 313 | %{"ids" => ids}, 314 | %{ 315 | assigns: %{ 316 | resource: resource, 317 | session: session, 318 | prefix: prefix, 319 | repo: repo 320 | } 321 | } = socket 322 | ) do 323 | results = 324 | ids 325 | |> Resource.all(resource, prefix, repo) 326 | |> Enum.map(fn record -> 327 | Task.Supervisor.async(LiveAdmin.Task.Supervisor, fn -> 328 | Resource.delete(record, resource, session, socket.assigns.repo) 329 | end) 330 | end) 331 | |> Task.await_many() 332 | 333 | socket = 334 | socket 335 | |> put_flash( 336 | :info, 337 | trans("Deleted %{count} records", inter: [count: Enum.count(results)]) 338 | ) 339 | |> push_navigate(to: route_with_params(socket.assigns)) 340 | 341 | {:noreply, socket} 342 | end 343 | 344 | @impl true 345 | def handle_event( 346 | "action", 347 | %{"action" => action, "ids" => ids}, 348 | socket = %{assigns: %{resource: resource, prefix: prefix, repo: repo}} 349 | ) do 350 | records = Resource.all(ids, resource, prefix, repo) 351 | 352 | action_name = String.to_existing_atom(action) 353 | 354 | results = 355 | records 356 | |> Enum.map(fn record -> 357 | Task.Supervisor.async(LiveAdmin.Task.Supervisor, fn -> 358 | {m, f, a} = 359 | :actions 360 | |> resource.__live_admin_config__() 361 | |> Enum.find_value(fn 362 | {^action_name, mfa} -> mfa 363 | ^action_name -> {resource, action_name, []} 364 | _ -> false 365 | end) 366 | 367 | apply(m, f, [record, socket.assigns.session] ++ a) 368 | end) 369 | end) 370 | |> Task.await_many() 371 | 372 | socket = 373 | socket 374 | |> put_flash( 375 | :info, 376 | trans("Action completed on %{count} records: %{action}", 377 | inter: [count: Enum.count(results), action: action] 378 | ) 379 | ) 380 | |> push_navigate(to: route_with_params(socket.assigns)) 381 | 382 | {:noreply, socket} 383 | end 384 | 385 | defp list_link_params(assigns, params) do 386 | assigns 387 | |> Map.take([:search, :page, :sort_attr, :sort_dir, :prefix]) 388 | |> Enum.into([]) 389 | |> Keyword.merge(params) 390 | end 391 | 392 | defp type_to_css_class({_, type, _}), do: type_to_css_class(type) 393 | defp type_to_css_class({:array, {_, type, _}}), do: {:array, type} |> type_to_css_class() 394 | defp type_to_css_class({:array, type}), do: "array.#{type}" |> type_to_css_class() 395 | 396 | defp type_to_css_class(type), 397 | do: type |> to_string() |> Phoenix.Naming.underscore() |> String.replace("/", "_") 398 | end 399 | -------------------------------------------------------------------------------- /dev.exs: -------------------------------------------------------------------------------- 1 | Logger.configure(level: :debug) 2 | 3 | pg_url = System.get_env("PG_URL") || "postgres:postgres@127.0.0.1" 4 | 5 | Application.put_env(:live_admin, Demo.Repo, 6 | url: "ecto://#{pg_url}/phx_admin_dev" 7 | ) 8 | 9 | defmodule Demo.Repo do 10 | use Ecto.Repo, otp_app: :live_admin, adapter: Ecto.Adapters.Postgres 11 | 12 | def prefixes, do: ["public", "alt"] 13 | end 14 | 15 | _ = Ecto.Adapters.Postgres.storage_up(Demo.Repo.config()) 16 | 17 | Application.put_env(:live_admin, DemoWeb.Endpoint, 18 | url: [host: "localhost"], 19 | secret_key_base: "Hu4qQN3iKzTV4fJxhorPQlA/osH9fAMtbtjVS58PFgfw3ja5Z18Q/WSNR9wP4OfW", 20 | live_view: [signing_salt: "hMegieSe"], 21 | http: [port: System.get_env("PORT") || 4000], 22 | debug_errors: true, 23 | check_origin: false, 24 | watchers: [ 25 | npm: ["run", "watch", cd: "assets"], 26 | npx: [ 27 | "tailwindcss", 28 | "--input=css/app.css", 29 | "--output=../dist/css/app.css", 30 | "--postcss", 31 | "--watch", 32 | cd: "assets" 33 | ] 34 | ], 35 | live_reload: [ 36 | patterns: [ 37 | ~r"dist/.*(js|css|png|jpeg|jpg|gif|svg)$", 38 | ~r"lib/live_admin/components/.*(ex)$", 39 | ~r"lib/live_admin/templates/.*/.*(ex)$", 40 | ~r"lib/live_admin/.*(ex)$" 41 | ] 42 | ], 43 | pubsub_server: Demo.PubSub 44 | ) 45 | 46 | Application.put_env(:live_admin, :ecto_repo, Demo.Repo) 47 | Application.put_env(:live_admin, :immutable_fields, [:inserted_at]) 48 | Application.put_env(:live_admin, :css_overrides, {DemoWeb.Renderer, :render_css, []}) 49 | Application.put_env(:live_admin, :gettext_backend, Demo.Gettext) 50 | 51 | defmodule DemoWeb.Renderer do 52 | use Phoenix.HTML 53 | 54 | def render_field(record, field, _session) do 55 | record 56 | |> Map.fetch!(field) 57 | |> case do 58 | bool when is_boolean(bool) -> 59 | if bool, do: "Yes", else: "No" 60 | date = %Date{} -> 61 | Calendar.strftime(date, "%a, %B %d %Y") 62 | _ -> 63 | record 64 | |> Map.fetch!(field) 65 | |> case do 66 | val when is_binary(val) -> val 67 | val -> inspect(val, pretty: true) 68 | end 69 | end 70 | end 71 | 72 | def render_css(%{metadata: %{"css_theme" => "dark"}}) do 73 | """ 74 | body { 75 | background-color: #444; 76 | color: #fff; 77 | } 78 | 79 | .nav { 80 | background-color: #444; 81 | } 82 | 83 | .resource__action--btn { 84 | background-color: #aaa; 85 | border-color: #fff; 86 | color: #000; 87 | } 88 | 89 | .resource__action--btn:hover { 90 | background-color: #ccc; 91 | border-color: #iii; 92 | } 93 | 94 | .resource__menu--drop nav { 95 | color: #000; 96 | background-color: #ccc; 97 | border-color: #iii; 98 | } 99 | 100 | .resource__header { 101 | background-color: #bbb; 102 | color: #000 103 | } 104 | 105 | .nav a:hover { 106 | background-color: #ccc; 107 | } 108 | 109 | .toast__container--error { 110 | border-color: violet; 111 | } 112 | 113 | .toast__container--success { 114 | border-color: chartreuse; 115 | } 116 | """ 117 | end 118 | 119 | def render_css(_) do 120 | __DIR__ 121 | |> Path.join("dist/css/default_overrides.css") 122 | |> File.read!() 123 | end 124 | end 125 | 126 | defmodule DemoWeb.PageController do 127 | import Plug.Conn 128 | 129 | def init(opts), do: opts 130 | 131 | def call(conn, :index) do 132 | content(conn, """ 133 |

LiveAdmin Dev

134 | Users 135 | Posts 136 | """) 137 | end 138 | 139 | defp content(conn, content) do 140 | conn 141 | |> put_resp_header("content-type", "text/html") 142 | |> send_resp(200, "#{content}") 143 | end 144 | end 145 | 146 | defmodule Demo.Accounts.User.Settings.Config do 147 | use Ecto.Schema 148 | 149 | @primary_key false 150 | embedded_schema do 151 | field :key, :string 152 | field :val, :string 153 | 154 | field :good, :boolean 155 | field :legal, :boolean 156 | end 157 | end 158 | 159 | defmodule Demo.Accounts.User.Settings do 160 | use Ecto.Schema 161 | 162 | embedded_schema do 163 | field :some_option, :string 164 | 165 | embeds_many :configs, __MODULE__.Config, on_replace: :delete 166 | end 167 | end 168 | 169 | defmodule Demo.Accounts.User.Profile do 170 | use Ecto.Schema 171 | use LiveAdmin.Resource, create_with: false 172 | 173 | schema "user_profiles" do 174 | belongs_to :user, Demo.Accounts.User, type: :binary_id 175 | end 176 | end 177 | 178 | defmodule Demo.Accounts.User do 179 | use Ecto.Schema 180 | 181 | @primary_key {:id, :binary_id, autogenerate: true} 182 | schema "users" do 183 | field :name, :string 184 | field :email, :string 185 | field :active, :boolean 186 | field :birth_date, :date 187 | field :stars_count, :integer 188 | field :private_data, :map 189 | field :encrypted_password, :string 190 | field :status, Ecto.Enum, values: [:active, :suspended] 191 | field :roles, {:array, Ecto.Enum}, values: [:admin, :staff] 192 | field :rating, :float 193 | 194 | field :password, :string, virtual: true 195 | 196 | embeds_one :settings, Demo.Accounts.User.Settings, on_replace: :delete 197 | 198 | has_many :posts, Demo.Posts.Post 199 | 200 | timestamps(updated_at: false) 201 | end 202 | end 203 | 204 | defmodule Demo.Posts.Post.Version do 205 | use Ecto.Schema 206 | 207 | @primary_key false 208 | embedded_schema do 209 | field :body, :string 210 | field :tags, {:array, :string} 211 | 212 | timestamps(updated_at: false) 213 | end 214 | end 215 | 216 | defmodule Demo.Posts.Post do 217 | use Ecto.Schema 218 | use LiveAdmin.Resource, 219 | immutable_fields: [:disabled_user_id], 220 | tasks: [:fail], 221 | validate_with: :validate, 222 | update_with: :update, 223 | ecto_repo: Demo.Repo 224 | 225 | import Ecto.Changeset 226 | 227 | schema "posts" do 228 | field :title, :string 229 | field :body, :string 230 | field :tags, {:array, :string}, default: [] 231 | field :categories, {:array, Ecto.Enum}, values: [:personal, :work] 232 | field :status, Ecto.Enum, values: [:draft, :archived, :live] 233 | field :metadata, :map 234 | 235 | embeds_many :previous_versions, __MODULE__.Version, on_replace: :delete 236 | 237 | belongs_to :user, Demo.Accounts.User, type: :binary_id 238 | belongs_to :disabled_user, Demo.Accounts.User, type: :binary_id 239 | 240 | timestamps(updated_at: false) 241 | end 242 | 243 | def fail(_) do 244 | {:error, "failed"} 245 | end 246 | 247 | def validate(changeset, _) do 248 | changeset 249 | |> Ecto.Changeset.validate_required([:title, :body, :user_id]) 250 | |> Ecto.Changeset.validate_length(:title, max: 10, message: "cannot be longer than 10 characters") 251 | end 252 | 253 | def update(record, params, _) do 254 | record 255 | |> Ecto.Changeset.cast(params, [:title, :body, :user_id, :inserted_at, :tags, :categories]) 256 | |> Ecto.Changeset.cast_embed(:previous_versions, with: fn version, params -> 257 | Ecto.Changeset.cast(version, params, [:body, :tags, :inserted_at]) 258 | end) 259 | |> Ecto.Changeset.validate_required([:title, :body, :user_id, :inserted_at]) 260 | |> Ecto.Changeset.validate_length(:title, max: 10, message: "cannot be longer than 10 characters") 261 | |> Ecto.Changeset.validate_change(:title, fn _, new_title -> 262 | if !String.contains?(new_title, record.title) do 263 | [title: "must contain original"] 264 | else 265 | [] 266 | end 267 | end) 268 | |> Demo.Repo.update() 269 | end 270 | end 271 | 272 | defmodule Demo.Populator do 273 | import Ecto.Query 274 | 275 | alias Demo.Repo 276 | 277 | def reset do 278 | teardown() 279 | run() 280 | end 281 | 282 | def run do 283 | Enum.each(1..100, fn _ -> 284 | %Demo.Accounts.User{ 285 | name: Faker.Person.name(), 286 | email: "#{Ecto.UUID.generate()}@example.com", 287 | settings: %{}, 288 | active: true, 289 | birth_date: ~D[1999-12-31], 290 | stars_count: Enum.random(0..100), 291 | private_data: %{}, 292 | encrypted_password: :crypto.strong_rand_bytes(16) |> Base.encode16(), 293 | posts: [ 294 | %Demo.Posts.Post{ 295 | title: Faker.Lorem.paragraph(1) |> String.slice(0..9), 296 | body: Faker.Lorem.paragraphs() |> Enum.join("\n\n"), 297 | disabled_user: get_user_if(:rand.uniform(2) == 1) 298 | } 299 | ] 300 | } 301 | |> Demo.Repo.insert!() 302 | end) 303 | end 304 | 305 | defp teardown do 306 | Repo.delete_all(Demo.Accounts.User) 307 | Repo.delete_all(Demo.Posts.Post) 308 | Repo.delete_all(Demo.Accounts.User.Profile) 309 | end 310 | 311 | defp get_user_if(true), do: from(Demo.Accounts.User, order_by: fragment("RANDOM()"), limit: 1) |> Demo.Repo.one() 312 | defp get_user_if(false), do: nil 313 | end 314 | 315 | defmodule DemoWeb.CreateUserForm do 316 | use Phoenix.LiveComponent 317 | use Phoenix.HTML 318 | 319 | import LiveAdmin, only: [route_with_params: 2] 320 | import LiveAdmin.ErrorHelpers 321 | 322 | @impl true 323 | def update(assigns, socket) do 324 | socket = 325 | socket 326 | |> assign(assigns) 327 | |> assign(:changeset, Ecto.Changeset.change(%Demo.Accounts.User{})) 328 | 329 | {:ok, socket} 330 | end 331 | 332 | @impl true 333 | def render(assigns) do 334 | assigns = assign(assigns, :enabled, Enum.empty?(assigns.changeset.errors)) 335 | 336 | ~H""" 337 |
338 | <.form 339 | :let={f} 340 | for={@changeset} 341 | as={:params} 342 | phx-change="validate" 343 | phx-submit="create" 344 | phx-target={@myself} 345 | class="resource__form" 346 | > 347 | 348 |
349 | <%= label(f, :name, class: "field__label") %> 350 | <%= textarea(f, :name, rows: 1, class: "field__text") %> 351 | <%= error_tag(f, :name) %> 352 |
353 | 354 |
355 | <%= label(f, :email, class: "field__label") %> 356 | <%= textarea(f, :email, rows: 1, class: "field__text") %> 357 | <%= error_tag(f, :email) %> 358 |
359 | 360 |
361 | <%= label(f, :password, class: "field__label") %> 362 | <%= password_input(f, :password, class: "field__text", value: input_value(f, :password)) %> 363 | <%= error_tag(f, :password) %> 364 |
365 | 366 |
367 | <%= label(f, :password_confirmation, class: "field__label") %> 368 | <%= password_input(f, :password_confirmation, class: "field__text") %> 369 | <%= error_tag(f, :password_confirmation) %> 370 |
371 | 372 |
373 | <%= submit("Save", 374 | class: "resource__action#{if !@enabled, do: "--disabled", else: "--btn"}", 375 | disabled: !@enabled 376 | ) %> 377 |
378 | 379 |
380 | """ 381 | end 382 | 383 | @impl true 384 | def handle_event( 385 | "validate", 386 | %{"params" => params}, 387 | %{assigns: %{changeset: changeset}} = socket 388 | ) do 389 | changeset = 390 | changeset.data 391 | |> Ecto.Changeset.cast(params, [:name, :email, :password]) 392 | |> Ecto.Changeset.validate_required([:name, :email, :password]) 393 | |> Ecto.Changeset.validate_confirmation(:password) 394 | |> Map.put(:action, :validate) 395 | 396 | {:noreply, assign(socket, changeset: changeset)} 397 | end 398 | 399 | @impl true 400 | def handle_event("create", %{"params" => params}, socket = %{assigns: assigns}) do 401 | socket = 402 | %Demo.Accounts.User{} 403 | |> Ecto.Changeset.cast(params, [:name, :email, :stars_count, :roles]) 404 | |> Ecto.Changeset.validate_required([:name, :email]) 405 | |> Ecto.Changeset.unique_constraint(:email) 406 | |> Demo.Repo.insert(prefix: assigns.prefix) 407 | |> case do 408 | {:ok, _} -> push_redirect(socket, to: route_with_params(assigns, params: [prefix: assigns.prefix])) 409 | {:error, changeset} -> assign(socket, changeset: changeset) 410 | end 411 | 412 | {:noreply, socket} 413 | end 414 | end 415 | 416 | defmodule DemoWeb.UserAdmin do 417 | use LiveAdmin.Resource, 418 | schema: Demo.Accounts.User, 419 | hidden_fields: [:private_data], 420 | immutable_fields: [:encrypted_password, :inserted_at], 421 | components: [new: DemoWeb.CreateUserForm], 422 | label_with: :name, 423 | actions: [:deactivate, :fake, :fake], 424 | tasks: [:regenerate_passwords], 425 | render_with: :render_field 426 | 427 | def deactivate(user, _) do 428 | user 429 | |> Ecto.Changeset.change(active: false) 430 | |> Demo.Repo.update() 431 | |> case do 432 | {:ok, user} -> {:ok, user} 433 | error -> error 434 | end 435 | end 436 | 437 | def render_field(user, :email, _) do 438 | Phoenix.HTML.Link.link(user.email, to: "mailto:\"#{user.name}\"<#{user.email}>") 439 | end 440 | 441 | def render_field(record, field, session) do 442 | DemoWeb.Renderer.render_field(record, field, session) 443 | end 444 | 445 | def regenerate_passwords(_) do 446 | Demo.Accounts.User 447 | |> Demo.Repo.all() 448 | |> Enum.each(fn user -> 449 | user 450 | |> Ecto.Changeset.change(encrypted_password: :crypto.strong_rand_bytes(16) |> Base.encode16()) 451 | |> Demo.Repo.update() 452 | end) 453 | 454 | {:ok, "updated"} 455 | end 456 | end 457 | 458 | defmodule DemoWeb.PostsAdmin.Home do 459 | use Phoenix.LiveComponent 460 | 461 | @impl true 462 | def render(assigns) do 463 | ~H""" 464 |
465 |
466 | This is only for managing posts 467 |
468 |
469 | """ 470 | end 471 | end 472 | 473 | 474 | defmodule DemoWeb.Router do 475 | use Phoenix.Router 476 | 477 | import LiveAdmin.Router 478 | import Phoenix.LiveView.Router 479 | 480 | pipeline :browser do 481 | plug :fetch_session 482 | 483 | plug :user_id_stub 484 | end 485 | scope "/" do 486 | pipe_through :browser 487 | get "/", DemoWeb.PageController, :index 488 | 489 | live_admin "/admin", title: "DevAdmin" do 490 | admin_resource "/users/profiles", Demo.Accounts.User.Profile 491 | admin_resource "/users", DemoWeb.UserAdmin 492 | end 493 | 494 | live_admin "/posts-admin", components: [home: DemoWeb.PostsAdmin.Home] do 495 | admin_resource "/posts", Demo.Posts.Post 496 | admin_resource "/users", DemoWeb.UserAdmin 497 | end 498 | end 499 | 500 | defp user_id_stub(conn, _) do 501 | Plug.Conn.assign(conn, :user_id, 1) 502 | end 503 | end 504 | 505 | defmodule DemoWeb.Endpoint do 506 | use Phoenix.Endpoint, otp_app: :live_admin 507 | 508 | socket "/live", Phoenix.LiveView.Socket 509 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 510 | 511 | plug Phoenix.LiveReloader 512 | plug Phoenix.CodeReloader 513 | 514 | plug Plug.Session, 515 | store: :cookie, 516 | key: "_live_view_key", 517 | signing_salt: "/VEDsdfsffMnp5" 518 | 519 | plug DemoWeb.Router 520 | end 521 | 522 | defmodule Demo.Gettext do 523 | use Gettext, otp_app: :demo, priv: "dev/gettext" 524 | 525 | def locales, do: ["en", "tr"] 526 | end 527 | 528 | Application.put_env(:phoenix, :serve_endpoints, true) 529 | 530 | Application.ensure_all_started(:os_mon) 531 | 532 | Task.async(fn -> 533 | children = [Demo.Repo, DemoWeb.Endpoint, {Phoenix.PubSub, name: Demo.PubSub, adapter: Phoenix.PubSub.PG2}] 534 | 535 | {:ok, _} = Supervisor.start_link(children, strategy: :one_for_one) 536 | 537 | Demo.Populator.reset() 538 | 539 | Process.sleep(:infinity) 540 | end) 541 | |> Task.await(:infinity) 542 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | * { 7 | @apply bg-inherit; 8 | } 9 | 10 | h1 { 11 | @apply text-3xl; 12 | @apply md:text-5xl; 13 | @apply mb-4; 14 | @apply font-extrabold; 15 | } 16 | 17 | input, textarea { 18 | @apply relative; 19 | @apply rounded; 20 | @apply text-sm; 21 | @apply border; 22 | @apply outline-none; 23 | @apply focus:outline-none; 24 | @apply focus:ring; 25 | @apply p-1; 26 | @apply overflow-hidden; 27 | } 28 | 29 | select { 30 | @apply appearance-none; 31 | @apply bg-clip-padding; 32 | @apply bg-no-repeat; 33 | @apply border; 34 | @apply border-solid; 35 | @apply rounded; 36 | @apply transition; 37 | @apply ease-in-out; 38 | @apply focus:outline-none; 39 | @apply w-32; 40 | @apply p-1; 41 | } 42 | } 43 | 44 | input[type="date"], input[type="number"] { 45 | @apply w-32; 46 | } 47 | 48 | @layer components { 49 | .view__container { 50 | @apply overflow-y-auto; 51 | } 52 | 53 | #list .table__wrapper { 54 | @apply overflow-auto; 55 | @apply max-h-full; 56 | } 57 | 58 | #prefix-select nav { 59 | @apply overflow-y-auto; 60 | } 61 | 62 | .topbar { 63 | @apply bg-transparent; 64 | } 65 | 66 | .disabled { 67 | @apply opacity-30; 68 | } 69 | 70 | .drop::before { 71 | content: ' '; 72 | display: inline-block; 73 | border-top: 5px solid transparent; 74 | border-bottom: 5px solid transparent; 75 | border-left: 5px solid currentColor; 76 | vertical-align: middle; 77 | margin-right: .7rem; 78 | transform: translateY(-2px); 79 | transition: transform .2s ease-out; 80 | } 81 | 82 | .drop-down::before { 83 | transform: rotate(90deg) translateX(-3px); 84 | } 85 | 86 | .drop-up::before { 87 | transform: rotate(-90deg) translateX(3px); 88 | } 89 | 90 | .button__add { 91 | @apply inline-block; 92 | content: " "; 93 | width: 24px; 94 | height: 24px; 95 | background: url("data:image/svg+xml;utf8,"); 96 | } 97 | 98 | .button__remove { 99 | @apply inline-block; 100 | content: " "; 101 | width: 24px; 102 | height: 24px; 103 | background: url("data:image/svg+xml;utf8,"); 104 | } 105 | 106 | .button__up { 107 | @apply inline-block; 108 | content: " "; 109 | width: 24px; 110 | height: 24px; 111 | background: url("data:image/svg+xml;utf8,"); 112 | } 113 | 114 | .button__down { 115 | @apply inline-block; 116 | content: " "; 117 | width: 24px; 118 | height: 24px; 119 | background: url("data:image/svg+xml;utf8,"); 120 | } 121 | 122 | @keyframes spinner { 123 | to {transform: rotate(360deg);} 124 | } 125 | 126 | input[class$="-loading"] + div nav:before { 127 | content: ''; 128 | box-sizing: border-box; 129 | width: 20px; 130 | height: 20px; 131 | border-radius: 50%; 132 | border: 2px solid #ccc; 133 | border-top-color: #000; 134 | animation: spinner .6s linear infinite; 135 | 136 | @apply absolute; 137 | @apply top-1; 138 | @apply right-1; 139 | } 140 | 141 | .button__remove, .button__add { 142 | @apply opacity-50; 143 | } 144 | 145 | .button__remove:hover, .button__add:hover { 146 | @apply opacity-100; 147 | } 148 | 149 | .search_select { 150 | @apply flex; 151 | @apply flex-row; 152 | @apply content-center; 153 | } 154 | 155 | .search_select nav { 156 | @apply w-full; 157 | } 158 | 159 | .search_select .button__remove { 160 | @apply mr-1; 161 | } 162 | 163 | .main__content { 164 | @apply flex; 165 | @apply flex-col; 166 | } 167 | 168 | .main__wrap { 169 | @apply flex; 170 | @apply overflow-hidden; 171 | @apply h-screen; 172 | } 173 | 174 | .nav { 175 | @apply overflow-y-auto; 176 | @apply w-1/5; 177 | @apply p-4; 178 | } 179 | 180 | .nav__list { 181 | @apply content-center; 182 | @apply justify-between; 183 | } 184 | 185 | .nav__list > .nav__item:first-of-type { 186 | @apply pb-5; 187 | @apply text-3xl; 188 | } 189 | 190 | .nav__item { 191 | @apply truncate; 192 | } 193 | 194 | .nav__item--drop { 195 | @apply ml-1; 196 | } 197 | 198 | .nav__item--drop > ul { 199 | @apply ml-4; 200 | } 201 | 202 | .nav__item--group { 203 | @apply nav__item; 204 | @apply border-b; 205 | @apply py-1; 206 | } 207 | 208 | .nav__item--selected { 209 | @apply nav__item; 210 | } 211 | 212 | .nav__item--selected a { 213 | @apply underline; 214 | @apply decoration-dotted; 215 | } 216 | 217 | .nav__item a { 218 | @apply p-1; 219 | @apply rounded; 220 | } 221 | 222 | .nav__item--active { 223 | @apply p-2; 224 | @apply truncate; 225 | } 226 | 227 | .nav__item--drop { 228 | @apply whitespace-nowrap; 229 | } 230 | 231 | .nav__item--drop input { 232 | @apply hidden; 233 | } 234 | 235 | .nav__item--drop label { 236 | @apply pl-1; 237 | @apply font-bold; 238 | cursor: pointer; 239 | transition: all 0.25s ease-out; 240 | } 241 | 242 | .nav__item--drop label { 243 | @apply drop; 244 | } 245 | 246 | .nav__item--drop input:checked + label { 247 | @apply drop-down; 248 | } 249 | 250 | .nav__item--drop ul { 251 | @apply hidden; 252 | } 253 | 254 | .nav__item--drop input:checked + label + ul { 255 | @apply list-item; 256 | } 257 | 258 | .content { 259 | @apply flex; 260 | @apply flex-col; 261 | @apply w-4/5; 262 | @apply p-2; 263 | } 264 | 265 | .home__intro { 266 | @apply py-2; 267 | @apply text-xl; 268 | } 269 | 270 | .resource__banner { 271 | @apply grid; 272 | @apply grid-cols-1; 273 | @apply lg:grid-cols-2; 274 | @apply whitespace-nowrap; 275 | @apply overflow-x-clip; 276 | @apply mb-2; 277 | } 278 | 279 | .resource__title { 280 | @apply grid; 281 | @apply items-center; 282 | @apply justify-items-center; 283 | @apply lg:justify-items-end; 284 | @apply h-full; 285 | direction: rtl; 286 | } 287 | 288 | .resource__actions { 289 | @apply flex-col; 290 | @apply flex-1; 291 | @apply items-center; 292 | @apply grid; 293 | @apply justify-items-center; 294 | @apply lg:justify-items-end; 295 | @apply whitespace-nowrap; 296 | } 297 | 298 | .resource__actions > div > * { 299 | @apply ml-2; 300 | } 301 | 302 | .resource__action { 303 | @apply inline-flex; 304 | @apply h-8; 305 | } 306 | 307 | .resource__action--link:hover { 308 | @apply underline; 309 | } 310 | 311 | .resource__action--btn, .resource__action--secondary { 312 | @apply resource__action; 313 | @apply items-center; 314 | @apply px-4; 315 | @apply text-sm; 316 | @apply transition-colors; 317 | @apply duration-150; 318 | @apply relative; 319 | @apply rounded-lg; 320 | @apply relative; 321 | @apply whitespace-nowrap; 322 | @apply border; 323 | } 324 | 325 | .resource__action--disabled { 326 | @apply resource__action--btn; 327 | @apply disabled; 328 | } 329 | 330 | .resource__action--danger { 331 | @apply resource__action--btn; 332 | } 333 | 334 | .resource__action--drop { 335 | @apply resource__action; 336 | } 337 | 338 | [class$="--drop"] { 339 | @apply flex-col; 340 | } 341 | 342 | [class$="--drop"] div { 343 | @apply relative; 344 | } 345 | 346 | [class$="--drop"] nav { 347 | @apply appearance-none; 348 | @apply hidden; 349 | @apply absolute; 350 | @apply left-0; 351 | @apply border; 352 | @apply rounded-md; 353 | @apply pl-2; 354 | @apply truncate; 355 | @apply z-50; 356 | @apply max-h-80; 357 | @apply p-1; 358 | } 359 | 360 | [class$="--drop"] div:first-child nav { 361 | @apply bottom-2; 362 | } 363 | 364 | [class$="--drop"] div:last-child nav { 365 | @apply top-1; 366 | } 367 | 368 | [class$="--drop"]:focus-within nav { 369 | @apply block; 370 | @apply translate-y-1; 371 | @apply bg-white; 372 | } 373 | 374 | [class$="--drop"] nav a:hover { 375 | @apply underline; 376 | } 377 | 378 | .resource__view dd { 379 | @apply mb-3; 380 | } 381 | 382 | #index-page { 383 | @apply overflow-x-auto; 384 | } 385 | 386 | tfoot td { 387 | @apply p-1; 388 | } 389 | 390 | .list__search { 391 | @apply bg-transparent; 392 | @apply flex; 393 | @apply m-1; 394 | } 395 | 396 | .list__search input { 397 | @apply px-4; 398 | @apply py-1; 399 | @apply w-60; 400 | @apply border-0; 401 | @apply h-8; 402 | } 403 | 404 | .list__search svg { 405 | @apply w-5; 406 | @apply h-5; 407 | } 408 | 409 | .resource__table { 410 | @apply p-2; 411 | @apply m-1; 412 | @apply shadow-md; 413 | @apply rounded; 414 | @apply border-collapse; 415 | @apply border; 416 | @apply relative; 417 | } 418 | 419 | .resource__table th { 420 | @apply sticky; 421 | @apply z-10; 422 | @apply top-0; 423 | } 424 | 425 | .resource__table tfoot td { 426 | @apply p-1; 427 | @apply bottom-0; 428 | @apply sticky; 429 | } 430 | 431 | .resource__table dd { 432 | @apply mb-5; 433 | } 434 | 435 | .resource__header { 436 | @apply border; 437 | @apply px-8; 438 | @apply py-4; 439 | @apply whitespace-nowrap; 440 | } 441 | 442 | .header__link--down { 443 | @apply drop; 444 | @apply drop-down; 445 | } 446 | 447 | .header__link--up { 448 | @apply drop; 449 | @apply drop-up; 450 | } 451 | 452 | [class^="resource__cell"] { 453 | @apply px-4; 454 | @apply py-2; 455 | @apply h-20; 456 | @apply relative; 457 | } 458 | 459 | .resource__cell:not(:first-child) .cell__contents { 460 | @apply overflow-y-auto; 461 | } 462 | 463 | .cell__contents { 464 | @apply flex; 465 | @apply flex-col; 466 | @apply h-full; 467 | @apply w-full; 468 | @apply justify-center; 469 | align-items: safe center; 470 | } 471 | 472 | .resource__menu--drop svg { 473 | @apply w-5; 474 | @apply h-5; 475 | } 476 | 477 | .cell__copy { 478 | @apply cursor-pointer; 479 | } 480 | 481 | .resource__form { 482 | @apply shadow-md; 483 | @apply rounded; 484 | @apply border-collapse; 485 | @apply border; 486 | @apply w-3/4; 487 | @apply shadow-md; 488 | @apply p-2; 489 | @apply m-1; 490 | @apply w-full; 491 | } 492 | 493 | .form__actions { 494 | @apply flex; 495 | @apply justify-end; 496 | } 497 | 498 | .form__actions > * { 499 | @apply ml-1; 500 | } 501 | 502 | .table__actions { 503 | @apply flex; 504 | } 505 | 506 | .table__actions * { 507 | @apply mr-2; 508 | } 509 | 510 | .embed__sort, .embed__drop { 511 | @apply hidden; 512 | } 513 | 514 | .embed__title { 515 | @apply mb-2; 516 | @apply uppercase; 517 | @apply font-bold; 518 | @apply text-lg; 519 | } 520 | 521 | .embed__group { 522 | @apply border-l; 523 | @apply border-dashed; 524 | @apply pb-5; 525 | @apply relative; 526 | @apply ml-3; 527 | } 528 | 529 | .embed__group .button__add { 530 | @apply absolute; 531 | @apply -bottom-2; 532 | @apply -left-3; 533 | } 534 | 535 | .embed__item > .button__remove { 536 | @apply absolute; 537 | @apply -top-2; 538 | @apply -left-2; 539 | } 540 | 541 | .embed__item > .button__up { 542 | @apply absolute; 543 | @apply -top-2; 544 | @apply -right-2; 545 | } 546 | 547 | .embed__item > .button__down { 548 | @apply absolute; 549 | @apply -bottom-2; 550 | @apply -right-2; 551 | } 552 | 553 | .embed__item { 554 | @apply relative; 555 | @apply border; 556 | @apply border-dotted; 557 | @apply ml-5; 558 | @apply mt-3; 559 | } 560 | 561 | .embed__item > div { 562 | @apply flex-col; 563 | @apply p-3; 564 | @apply grow; 565 | } 566 | 567 | .field__group { 568 | @apply flex; 569 | @apply flex-col; 570 | @apply mb-4; 571 | } 572 | 573 | 574 | .field__group--disabled { 575 | @apply field__group; 576 | @apply disabled; 577 | } 578 | 579 | .field__label { 580 | @apply mb-2 uppercase; 581 | @apply font-bold; 582 | @apply text-lg; 583 | } 584 | 585 | .checkbox__group input { 586 | @apply scale-150; 587 | } 588 | 589 | .checkbox__group label { 590 | @apply ml-1; 591 | } 592 | 593 | .checkbox__group { 594 | @apply grid; 595 | grid-template-columns: auto minmax(0, 1fr); 596 | @apply gap-1; 597 | @apply justify-items-start; 598 | @apply ml-3; 599 | } 600 | 601 | .field__array--group { 602 | @apply relative; 603 | @apply pb-6; 604 | } 605 | 606 | .field__array--group a.button__add { 607 | @apply absolute; 608 | @apply -left-0; 609 | @apply -bottom-1; 610 | } 611 | 612 | .field__array--group > div { 613 | @apply flex; 614 | @apply items-center; 615 | @apply mb-2; 616 | } 617 | 618 | .field__array--group > div > a { 619 | @apply shrink-0; 620 | @apply mr-2; 621 | } 622 | 623 | .field__array--group input { 624 | @apply mb-0; 625 | } 626 | 627 | .field__map--group > div { 628 | @apply relative; 629 | @apply pb-6; 630 | } 631 | 632 | .field__map--group a.button__remove { 633 | @apply shrink-0; 634 | @apply mr-2; 635 | } 636 | 637 | .field__map--group a.button__add { 638 | @apply absolute; 639 | @apply left-0; 640 | @apply bottom-0; 641 | } 642 | 643 | .field__map--row { 644 | @apply flex; 645 | @apply items-center; 646 | @apply mb-2; 647 | } 648 | 649 | .field__map--row textarea { 650 | @apply mb-0; 651 | @apply mr-2; 652 | @apply w-1/4; 653 | } 654 | 655 | .toast__container { 656 | @apply fixed; 657 | @apply w-48; 658 | @apply z-40; 659 | @apply rounded; 660 | @apply p-1; 661 | @apply text-center; 662 | @apply border; 663 | @apply bottom-5; 664 | @apply text-clip; 665 | @apply overflow-hidden; 666 | 667 | left: 50%; 668 | top: auto !important; 669 | transform: translate(-50%, -50%) !important; 670 | } 671 | 672 | [class^="toast__container"]:empty { 673 | display: none; 674 | } 675 | 676 | .toast__container--info { 677 | @apply toast__container; 678 | top: auto !important; 679 | transform: translate(-50%, -50%) !important; 680 | } 681 | 682 | .toast__container--error { 683 | @apply toast__container; 684 | top: auto !important; 685 | transform: translate(-50%, -50%) !important; 686 | } 687 | 688 | .toast__container--success { 689 | @apply toast__container; 690 | top: auto !important; 691 | transform: translate(-50%, -50%) !important; 692 | } 693 | } 694 | 695 | .cell__icons { 696 | display: none; 697 | position: absolute; 698 | bottom: 0px; 699 | right: 0px; 700 | margin: 0.25rem; 701 | } 702 | 703 | .cell__icons div { 704 | cursor: pointer; 705 | @apply mx-1; 706 | } 707 | 708 | [class^="resource__cell"]:hover .cell__icons { 709 | @apply flex; 710 | } 711 | 712 | .resource__cell svg { 713 | @apply w-6; 714 | @apply h-6; 715 | } 716 | 717 | .field__assoc--link { 718 | @apply leading-9; 719 | @apply underline; 720 | vertical-align: bottom; 721 | } 722 | 723 | .field__assoc--link:hover::after { 724 | @apply leading-9; 725 | @apply inline-block; 726 | content: " "; 727 | background-image: url("data:image/svg+xml,"); 728 | width: 20px; 729 | height: 20px; 730 | @apply ml-2; 731 | @apply ml-2; 732 | } 733 | -------------------------------------------------------------------------------- /dist/css/app.css: -------------------------------------------------------------------------------- 1 | /* 2 | ! tailwindcss v3.0.11 | MIT License | https://tailwindcss.com 3 | */ 4 | 5 | /* 6 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 7 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 8 | */ 9 | 10 | *, 11 | ::before, 12 | ::after { 13 | box-sizing: border-box; 14 | /* 1 */ 15 | border-width: 0; 16 | /* 2 */ 17 | border-style: solid; 18 | /* 2 */ 19 | border-color: currentColor; 20 | /* 2 */ 21 | } 22 | 23 | ::before, 24 | ::after { 25 | --tw-content: ''; 26 | } 27 | 28 | /* 29 | 1. Use a consistent sensible line-height in all browsers. 30 | 2. Prevent adjustments of font size after orientation changes in iOS. 31 | 3. Use a more readable tab size. 32 | 4. Use the user's configured `sans` font-family by default. 33 | */ 34 | 35 | html { 36 | line-height: 1.5; 37 | /* 1 */ 38 | -webkit-text-size-adjust: 100%; 39 | /* 2 */ 40 | -moz-tab-size: 4; 41 | /* 3 */ 42 | -o-tab-size: 4; 43 | tab-size: 4; 44 | /* 3 */ 45 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 46 | /* 4 */ 47 | } 48 | 49 | /* 50 | 1. Remove the margin in all browsers. 51 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 52 | */ 53 | 54 | body { 55 | margin: 0; 56 | /* 1 */ 57 | line-height: inherit; 58 | /* 2 */ 59 | } 60 | 61 | /* 62 | 1. Add the correct height in Firefox. 63 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 64 | 3. Ensure horizontal rules are visible by default. 65 | */ 66 | 67 | hr { 68 | height: 0; 69 | /* 1 */ 70 | color: inherit; 71 | /* 2 */ 72 | border-top-width: 1px; 73 | /* 3 */ 74 | } 75 | 76 | /* 77 | Add the correct text decoration in Chrome, Edge, and Safari. 78 | */ 79 | 80 | abbr:where([title]) { 81 | -webkit-text-decoration: underline dotted; 82 | text-decoration: underline dotted; 83 | } 84 | 85 | /* 86 | Remove the default font size and weight for headings. 87 | */ 88 | 89 | h1, 90 | h2, 91 | h3, 92 | h4, 93 | h5, 94 | h6 { 95 | font-size: inherit; 96 | font-weight: inherit; 97 | } 98 | 99 | /* 100 | Reset links to optimize for opt-in styling instead of opt-out. 101 | */ 102 | 103 | a { 104 | color: inherit; 105 | text-decoration: inherit; 106 | } 107 | 108 | /* 109 | Add the correct font weight in Edge and Safari. 110 | */ 111 | 112 | b, 113 | strong { 114 | font-weight: bolder; 115 | } 116 | 117 | /* 118 | 1. Use the user's configured `mono` font family by default. 119 | 2. Correct the odd `em` font sizing in all browsers. 120 | */ 121 | 122 | code, 123 | kbd, 124 | samp, 125 | pre { 126 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 127 | /* 1 */ 128 | font-size: 1em; 129 | /* 2 */ 130 | } 131 | 132 | /* 133 | Add the correct font size in all browsers. 134 | */ 135 | 136 | small { 137 | font-size: 80%; 138 | } 139 | 140 | /* 141 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 142 | */ 143 | 144 | sub, 145 | sup { 146 | font-size: 75%; 147 | line-height: 0; 148 | position: relative; 149 | vertical-align: baseline; 150 | } 151 | 152 | sub { 153 | bottom: -0.25em; 154 | } 155 | 156 | sup { 157 | top: -0.5em; 158 | } 159 | 160 | /* 161 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 162 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 163 | 3. Remove gaps between table borders by default. 164 | */ 165 | 166 | table { 167 | text-indent: 0; 168 | /* 1 */ 169 | border-color: inherit; 170 | /* 2 */ 171 | border-collapse: collapse; 172 | /* 3 */ 173 | } 174 | 175 | /* 176 | 1. Change the font styles in all browsers. 177 | 2. Remove the margin in Firefox and Safari. 178 | 3. Remove default padding in all browsers. 179 | */ 180 | 181 | button, 182 | input, 183 | optgroup, 184 | select, 185 | textarea { 186 | font-family: inherit; 187 | /* 1 */ 188 | font-size: 100%; 189 | /* 1 */ 190 | line-height: inherit; 191 | /* 1 */ 192 | color: inherit; 193 | /* 1 */ 194 | margin: 0; 195 | /* 2 */ 196 | padding: 0; 197 | /* 3 */ 198 | } 199 | 200 | /* 201 | Remove the inheritance of text transform in Edge and Firefox. 202 | */ 203 | 204 | button, 205 | select { 206 | text-transform: none; 207 | } 208 | 209 | /* 210 | 1. Correct the inability to style clickable types in iOS and Safari. 211 | 2. Remove default button styles. 212 | */ 213 | 214 | button, 215 | [type='button'], 216 | [type='reset'], 217 | [type='submit'] { 218 | -webkit-appearance: button; 219 | /* 1 */ 220 | background-color: transparent; 221 | /* 2 */ 222 | background-image: none; 223 | /* 2 */ 224 | } 225 | 226 | /* 227 | Use the modern Firefox focus style for all focusable elements. 228 | */ 229 | 230 | :-moz-focusring { 231 | outline: auto; 232 | } 233 | 234 | /* 235 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 236 | */ 237 | 238 | :-moz-ui-invalid { 239 | box-shadow: none; 240 | } 241 | 242 | /* 243 | Add the correct vertical alignment in Chrome and Firefox. 244 | */ 245 | 246 | progress { 247 | vertical-align: baseline; 248 | } 249 | 250 | /* 251 | Correct the cursor style of increment and decrement buttons in Safari. 252 | */ 253 | 254 | ::-webkit-inner-spin-button, 255 | ::-webkit-outer-spin-button { 256 | height: auto; 257 | } 258 | 259 | /* 260 | 1. Correct the odd appearance in Chrome and Safari. 261 | 2. Correct the outline style in Safari. 262 | */ 263 | 264 | [type='search'] { 265 | -webkit-appearance: textfield; 266 | /* 1 */ 267 | outline-offset: -2px; 268 | /* 2 */ 269 | } 270 | 271 | /* 272 | Remove the inner padding in Chrome and Safari on macOS. 273 | */ 274 | 275 | ::-webkit-search-decoration { 276 | -webkit-appearance: none; 277 | } 278 | 279 | /* 280 | 1. Correct the inability to style clickable types in iOS and Safari. 281 | 2. Change font properties to `inherit` in Safari. 282 | */ 283 | 284 | ::-webkit-file-upload-button { 285 | -webkit-appearance: button; 286 | /* 1 */ 287 | font: inherit; 288 | /* 2 */ 289 | } 290 | 291 | /* 292 | Add the correct display in Chrome and Safari. 293 | */ 294 | 295 | summary { 296 | display: list-item; 297 | } 298 | 299 | /* 300 | Removes the default spacing and border for appropriate elements. 301 | */ 302 | 303 | blockquote, 304 | dl, 305 | dd, 306 | h1, 307 | h2, 308 | h3, 309 | h4, 310 | h5, 311 | h6, 312 | hr, 313 | figure, 314 | p, 315 | pre { 316 | margin: 0; 317 | } 318 | 319 | fieldset { 320 | margin: 0; 321 | padding: 0; 322 | } 323 | 324 | legend { 325 | padding: 0; 326 | } 327 | 328 | ol, 329 | ul, 330 | menu { 331 | list-style: none; 332 | margin: 0; 333 | padding: 0; 334 | } 335 | 336 | /* 337 | Prevent resizing textareas horizontally by default. 338 | */ 339 | 340 | textarea { 341 | resize: vertical; 342 | } 343 | 344 | /* 345 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 346 | 2. Set the default placeholder color to the user's configured gray 400 color. 347 | */ 348 | 349 | input::-moz-placeholder, textarea::-moz-placeholder { 350 | opacity: 1; 351 | /* 1 */ 352 | color: #9ca3af; 353 | /* 2 */ 354 | } 355 | 356 | input:-ms-input-placeholder, textarea:-ms-input-placeholder { 357 | opacity: 1; 358 | /* 1 */ 359 | color: #9ca3af; 360 | /* 2 */ 361 | } 362 | 363 | input::placeholder, 364 | textarea::placeholder { 365 | opacity: 1; 366 | /* 1 */ 367 | color: #9ca3af; 368 | /* 2 */ 369 | } 370 | 371 | /* 372 | Set the default cursor for buttons. 373 | */ 374 | 375 | button, 376 | [role="button"] { 377 | cursor: pointer; 378 | } 379 | 380 | /* 381 | Make sure disabled buttons don't get the pointer cursor. 382 | */ 383 | 384 | :disabled { 385 | cursor: default; 386 | } 387 | 388 | /* 389 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 390 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 391 | This can trigger a poorly considered lint error in some tools but is included by design. 392 | */ 393 | 394 | img, 395 | svg, 396 | video, 397 | canvas, 398 | audio, 399 | iframe, 400 | embed, 401 | object { 402 | display: block; 403 | /* 1 */ 404 | vertical-align: middle; 405 | /* 2 */ 406 | } 407 | 408 | /* 409 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 410 | */ 411 | 412 | img, 413 | video { 414 | max-width: 100%; 415 | height: auto; 416 | } 417 | 418 | /* 419 | Ensure the default browser behavior of the `hidden` attribute. 420 | */ 421 | 422 | [hidden] { 423 | display: none; 424 | } 425 | 426 | * { 427 | background-color: inherit; 428 | } 429 | 430 | h1 { 431 | font-size: 1.875rem; 432 | line-height: 2.25rem; 433 | } 434 | 435 | @media (min-width: 768px) { 436 | h1 { 437 | font-size: 3rem; 438 | line-height: 1; 439 | } 440 | } 441 | 442 | h1 { 443 | margin-bottom: 1rem; 444 | font-weight: 800; 445 | } 446 | 447 | input, textarea { 448 | position: relative; 449 | border-radius: 0.25rem; 450 | font-size: 0.875rem; 451 | line-height: 1.25rem; 452 | border-width: 1px; 453 | outline: 2px solid transparent; 454 | outline-offset: 2px; 455 | } 456 | 457 | input:focus, textarea:focus { 458 | outline: 2px solid transparent; 459 | outline-offset: 2px; 460 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 461 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color); 462 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); 463 | } 464 | 465 | input, textarea { 466 | padding: 0.25rem; 467 | overflow: hidden; 468 | } 469 | 470 | select { 471 | -webkit-appearance: none; 472 | -moz-appearance: none; 473 | appearance: none; 474 | background-clip: padding-box; 475 | background-repeat: no-repeat; 476 | border-width: 1px; 477 | border-style: solid; 478 | border-radius: 0.25rem; 479 | transition-property: color, background-color, border-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-text-decoration-color, -webkit-backdrop-filter; 480 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; 481 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-text-decoration-color, -webkit-backdrop-filter; 482 | transition-duration: 150ms; 483 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 484 | } 485 | 486 | select:focus { 487 | outline: 2px solid transparent; 488 | outline-offset: 2px; 489 | } 490 | 491 | select { 492 | width: 8rem; 493 | padding: 0.25rem; 494 | } 495 | 496 | *, ::before, ::after { 497 | --tw-translate-x: 0; 498 | --tw-translate-y: 0; 499 | --tw-rotate: 0; 500 | --tw-skew-x: 0; 501 | --tw-skew-y: 0; 502 | --tw-scale-x: 1; 503 | --tw-scale-y: 1; 504 | --tw-transform: translateX(var(--tw-translate-x)) translateY(var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 505 | --tw-border-opacity: 1; 506 | border-color: rgb(229 231 235 / var(--tw-border-opacity)); 507 | --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); 508 | --tw-ring-offset-width: 0px; 509 | --tw-ring-offset-color: #fff; 510 | --tw-ring-color: rgb(59 130 246 / 0.5); 511 | --tw-ring-offset-shadow: 0 0 #0000; 512 | --tw-ring-shadow: 0 0 #0000; 513 | --tw-shadow: 0 0 #0000; 514 | --tw-shadow-colored: 0 0 #0000; 515 | --tw-blur: var(--tw-empty,/*!*/ /*!*/); 516 | --tw-brightness: var(--tw-empty,/*!*/ /*!*/); 517 | --tw-contrast: var(--tw-empty,/*!*/ /*!*/); 518 | --tw-grayscale: var(--tw-empty,/*!*/ /*!*/); 519 | --tw-hue-rotate: var(--tw-empty,/*!*/ /*!*/); 520 | --tw-invert: var(--tw-empty,/*!*/ /*!*/); 521 | --tw-saturate: var(--tw-empty,/*!*/ /*!*/); 522 | --tw-sepia: var(--tw-empty,/*!*/ /*!*/); 523 | --tw-drop-shadow: var(--tw-empty,/*!*/ /*!*/); 524 | --tw-filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); 525 | } 526 | 527 | .view__container { 528 | overflow-y: auto; 529 | } 530 | 531 | #list .table__wrapper { 532 | overflow: auto; 533 | max-height: 100%; 534 | } 535 | 536 | #prefix-select nav { 537 | overflow-y: auto; 538 | } 539 | 540 | .topbar { 541 | background-color: transparent; 542 | } 543 | 544 | .disabled { 545 | opacity: 0.3; 546 | } 547 | 548 | .drop::before { 549 | content: ' '; 550 | display: inline-block; 551 | border-top: 5px solid transparent; 552 | border-bottom: 5px solid transparent; 553 | border-left: 5px solid currentColor; 554 | vertical-align: middle; 555 | margin-right: .7rem; 556 | transform: translateY(-2px); 557 | transition: transform .2s ease-out; 558 | } 559 | 560 | .button__add { 561 | display: inline-block; 562 | content: " "; 563 | width: 24px; 564 | height: 24px; 565 | background: url("data:image/svg+xml;utf8,"); 566 | } 567 | 568 | .button__remove { 569 | display: inline-block; 570 | content: " "; 571 | width: 24px; 572 | height: 24px; 573 | background: url("data:image/svg+xml;utf8,"); 574 | } 575 | 576 | .button__up { 577 | display: inline-block; 578 | content: " "; 579 | width: 24px; 580 | height: 24px; 581 | background: url("data:image/svg+xml;utf8,"); 582 | } 583 | 584 | .button__down { 585 | display: inline-block; 586 | content: " "; 587 | width: 24px; 588 | height: 24px; 589 | background: url("data:image/svg+xml;utf8,"); 590 | } 591 | 592 | @-webkit-keyframes spinner { 593 | to { 594 | transform: rotate(360deg); 595 | } 596 | } 597 | 598 | @keyframes spinner { 599 | to { 600 | transform: rotate(360deg); 601 | } 602 | } 603 | 604 | input[class$="-loading"] + div nav:before { 605 | content: ''; 606 | box-sizing: border-box; 607 | width: 20px; 608 | height: 20px; 609 | border-radius: 50%; 610 | border: 2px solid #ccc; 611 | border-top-color: #000; 612 | -webkit-animation: spinner .6s linear infinite; 613 | animation: spinner .6s linear infinite; 614 | position: absolute; 615 | top: 0.25rem; 616 | right: 0.25rem; 617 | } 618 | 619 | .button__remove, .button__add { 620 | opacity: 0.5; 621 | } 622 | 623 | .button__remove:hover, .button__add:hover { 624 | opacity: 1; 625 | } 626 | 627 | .search_select { 628 | display: flex; 629 | flex-direction: row; 630 | align-content: center; 631 | } 632 | 633 | .search_select nav { 634 | width: 100%; 635 | } 636 | 637 | .search_select .button__remove { 638 | margin-right: 0.25rem; 639 | } 640 | 641 | .main__content { 642 | display: flex; 643 | flex-direction: column; 644 | } 645 | 646 | .main__wrap { 647 | display: flex; 648 | overflow: hidden; 649 | height: 100vh; 650 | } 651 | 652 | .nav { 653 | overflow-y: auto; 654 | width: 20%; 655 | padding: 1rem; 656 | } 657 | 658 | .nav__list { 659 | align-content: center; 660 | justify-content: space-between; 661 | } 662 | 663 | .nav__list > .nav__item:first-of-type { 664 | padding-bottom: 1.25rem; 665 | font-size: 1.875rem; 666 | line-height: 2.25rem; 667 | } 668 | 669 | .nav__item { 670 | overflow: hidden; 671 | text-overflow: ellipsis; 672 | white-space: nowrap; 673 | } 674 | 675 | .nav__item--drop { 676 | margin-left: 0.25rem; 677 | } 678 | 679 | .nav__item--drop > ul { 680 | margin-left: 1rem; 681 | } 682 | 683 | .nav__list > .nav__item--group:first-of-type { 684 | padding-bottom: 1.25rem; 685 | font-size: 1.875rem; 686 | line-height: 2.25rem; 687 | } 688 | 689 | .nav__item--group { 690 | overflow: hidden; 691 | text-overflow: ellipsis; 692 | white-space: nowrap; 693 | } 694 | 695 | .nav__item--group a { 696 | border-radius: 0.25rem; 697 | padding: 0.25rem; 698 | } 699 | 700 | .nav__item--group { 701 | border-bottom-width: 1px; 702 | padding-top: 0.25rem; 703 | padding-bottom: 0.25rem; 704 | } 705 | 706 | .nav__list > .nav__item--selected:first-of-type { 707 | padding-bottom: 1.25rem; 708 | font-size: 1.875rem; 709 | line-height: 2.25rem; 710 | } 711 | 712 | .nav__item--selected { 713 | overflow: hidden; 714 | text-overflow: ellipsis; 715 | white-space: nowrap; 716 | } 717 | 718 | .nav__item--selected a { 719 | border-radius: 0.25rem; 720 | padding: 0.25rem; 721 | -webkit-text-decoration-line: underline; 722 | text-decoration-line: underline; 723 | -webkit-text-decoration-style: dotted; 724 | text-decoration-style: dotted; 725 | } 726 | 727 | .nav__item a { 728 | padding: 0.25rem; 729 | border-radius: 0.25rem; 730 | } 731 | 732 | .nav__item--active { 733 | padding: 0.5rem; 734 | overflow: hidden; 735 | text-overflow: ellipsis; 736 | white-space: nowrap; 737 | } 738 | 739 | .nav__item--drop { 740 | white-space: nowrap; 741 | } 742 | 743 | .nav__item--drop input { 744 | display: none; 745 | } 746 | 747 | .nav__item--drop label { 748 | padding-left: 0.25rem; 749 | font-weight: 700; 750 | cursor: pointer; 751 | transition: all 0.25s ease-out; 752 | } 753 | 754 | .nav__item--drop label::before { 755 | content: ' '; 756 | display: inline-block; 757 | border-top: 5px solid transparent; 758 | border-bottom: 5px solid transparent; 759 | border-left: 5px solid currentColor; 760 | vertical-align: middle; 761 | margin-right: .7rem; 762 | transform: translateY(-2px); 763 | transition: transform .2s ease-out; 764 | } 765 | 766 | .nav__item--drop input:checked + label::before { 767 | transform: rotate(90deg) translateX(-3px); 768 | } 769 | 770 | .nav__item--drop ul { 771 | display: none; 772 | } 773 | 774 | .nav__item--drop input:checked + label + ul { 775 | display: list-item; 776 | } 777 | 778 | .content { 779 | display: flex; 780 | flex-direction: column; 781 | width: 80%; 782 | padding: 0.5rem; 783 | } 784 | 785 | .home__intro { 786 | padding-top: 0.5rem; 787 | padding-bottom: 0.5rem; 788 | font-size: 1.25rem; 789 | line-height: 1.75rem; 790 | } 791 | 792 | .resource__banner { 793 | display: grid; 794 | grid-template-columns: repeat(1, minmax(0, 1fr)); 795 | } 796 | 797 | @media (min-width: 1024px) { 798 | .resource__banner { 799 | grid-template-columns: repeat(2, minmax(0, 1fr)); 800 | } 801 | } 802 | 803 | .resource__banner { 804 | white-space: nowrap; 805 | overflow-x: clip; 806 | margin-bottom: 0.5rem; 807 | } 808 | 809 | .resource__title { 810 | display: grid; 811 | align-items: center; 812 | justify-items: center; 813 | } 814 | 815 | @media (min-width: 1024px) { 816 | .resource__title { 817 | justify-items: end; 818 | } 819 | } 820 | 821 | .resource__title { 822 | height: 100%; 823 | direction: rtl; 824 | } 825 | 826 | .resource__actions { 827 | flex-direction: column; 828 | flex: 1 1 0%; 829 | align-items: center; 830 | display: grid; 831 | justify-items: center; 832 | } 833 | 834 | @media (min-width: 1024px) { 835 | .resource__actions { 836 | justify-items: end; 837 | } 838 | } 839 | 840 | .resource__actions { 841 | white-space: nowrap; 842 | } 843 | 844 | .resource__actions > div > * { 845 | margin-left: 0.5rem; 846 | } 847 | 848 | .resource__action { 849 | display: inline-flex; 850 | height: 2rem; 851 | } 852 | 853 | .resource__action--link:hover { 854 | -webkit-text-decoration-line: underline; 855 | text-decoration-line: underline; 856 | } 857 | 858 | .resource__action--btn, .resource__action--secondary { 859 | display: inline-flex; 860 | height: 2rem; 861 | align-items: center; 862 | padding-left: 1rem; 863 | padding-right: 1rem; 864 | font-size: 0.875rem; 865 | line-height: 1.25rem; 866 | transition-property: color, background-color, border-color, fill, stroke, -webkit-text-decoration-color; 867 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; 868 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, -webkit-text-decoration-color; 869 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 870 | transition-duration: 150ms; 871 | border-radius: 0.5rem; 872 | position: relative; 873 | white-space: nowrap; 874 | border-width: 1px; 875 | } 876 | 877 | .resource__action--disabled { 878 | display: inline-flex; 879 | height: 2rem; 880 | position: relative; 881 | align-items: center; 882 | white-space: nowrap; 883 | border-radius: 0.5rem; 884 | border-width: 1px; 885 | padding-left: 1rem; 886 | padding-right: 1rem; 887 | font-size: 0.875rem; 888 | line-height: 1.25rem; 889 | transition-property: color, background-color, border-color, fill, stroke, -webkit-text-decoration-color; 890 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; 891 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, -webkit-text-decoration-color; 892 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 893 | transition-duration: 150ms; 894 | opacity: 0.3; 895 | } 896 | 897 | .resource__action--danger { 898 | display: inline-flex; 899 | height: 2rem; 900 | position: relative; 901 | align-items: center; 902 | white-space: nowrap; 903 | border-radius: 0.5rem; 904 | border-width: 1px; 905 | padding-left: 1rem; 906 | padding-right: 1rem; 907 | font-size: 0.875rem; 908 | line-height: 1.25rem; 909 | transition-property: color, background-color, border-color, fill, stroke, -webkit-text-decoration-color; 910 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; 911 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, -webkit-text-decoration-color; 912 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 913 | transition-duration: 150ms; 914 | } 915 | 916 | .resource__action--drop { 917 | display: inline-flex; 918 | height: 2rem; 919 | } 920 | 921 | [class$="--drop"] { 922 | flex-direction: column; 923 | } 924 | 925 | [class$="--drop"] div { 926 | position: relative; 927 | } 928 | 929 | [class$="--drop"] nav { 930 | -webkit-appearance: none; 931 | -moz-appearance: none; 932 | appearance: none; 933 | display: none; 934 | position: absolute; 935 | left: 0px; 936 | border-width: 1px; 937 | border-radius: 0.375rem; 938 | padding-left: 0.5rem; 939 | overflow: hidden; 940 | text-overflow: ellipsis; 941 | white-space: nowrap; 942 | z-index: 50; 943 | max-height: 20rem; 944 | padding: 0.25rem; 945 | } 946 | 947 | [class$="--drop"] div:first-child nav { 948 | bottom: 0.5rem; 949 | } 950 | 951 | [class$="--drop"] div:last-child nav { 952 | top: 0.25rem; 953 | } 954 | 955 | [class$="--drop"]:focus-within nav { 956 | display: block; 957 | --tw-translate-y: 0.25rem; 958 | transform: var(--tw-transform); 959 | --tw-bg-opacity: 1; 960 | background-color: rgb(255 255 255 / var(--tw-bg-opacity)); 961 | } 962 | 963 | [class$="--drop"] nav a:hover { 964 | -webkit-text-decoration-line: underline; 965 | text-decoration-line: underline; 966 | } 967 | 968 | .resource__view dd { 969 | margin-bottom: 0.75rem; 970 | } 971 | 972 | #index-page { 973 | overflow-x: auto; 974 | } 975 | 976 | tfoot td { 977 | padding: 0.25rem; 978 | } 979 | 980 | .list__search { 981 | background-color: transparent; 982 | display: flex; 983 | margin: 0.25rem; 984 | } 985 | 986 | .list__search input { 987 | padding-left: 1rem; 988 | padding-right: 1rem; 989 | padding-top: 0.25rem; 990 | padding-bottom: 0.25rem; 991 | width: 15rem; 992 | border-width: 0px; 993 | height: 2rem; 994 | } 995 | 996 | .list__search svg { 997 | width: 1.25rem; 998 | height: 1.25rem; 999 | } 1000 | 1001 | .resource__table { 1002 | padding: 0.5rem; 1003 | margin: 0.25rem; 1004 | --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 1005 | --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); 1006 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1007 | border-radius: 0.25rem; 1008 | border-collapse: collapse; 1009 | border-width: 1px; 1010 | position: relative; 1011 | } 1012 | 1013 | .resource__table th { 1014 | position: -webkit-sticky; 1015 | position: sticky; 1016 | z-index: 10; 1017 | top: 0px; 1018 | } 1019 | 1020 | .resource__table tfoot td { 1021 | padding: 0.25rem; 1022 | bottom: 0px; 1023 | position: -webkit-sticky; 1024 | position: sticky; 1025 | } 1026 | 1027 | .resource__table dd { 1028 | margin-bottom: 1.25rem; 1029 | } 1030 | 1031 | .resource__header { 1032 | border-width: 1px; 1033 | padding-left: 2rem; 1034 | padding-right: 2rem; 1035 | padding-top: 1rem; 1036 | padding-bottom: 1rem; 1037 | white-space: nowrap; 1038 | } 1039 | 1040 | .header__link--down::before { 1041 | content: ' '; 1042 | display: inline-block; 1043 | border-top: 5px solid transparent; 1044 | border-bottom: 5px solid transparent; 1045 | border-left: 5px solid currentColor; 1046 | vertical-align: middle; 1047 | margin-right: .7rem; 1048 | transform: translateY(-2px); 1049 | transition: transform .2s ease-out; 1050 | transform: rotate(90deg) translateX(-3px); 1051 | } 1052 | 1053 | .header__link--up::before { 1054 | content: ' '; 1055 | display: inline-block; 1056 | border-top: 5px solid transparent; 1057 | border-bottom: 5px solid transparent; 1058 | border-left: 5px solid currentColor; 1059 | vertical-align: middle; 1060 | margin-right: .7rem; 1061 | transform: translateY(-2px); 1062 | transition: transform .2s ease-out; 1063 | transform: rotate(-90deg) translateX(3px); 1064 | } 1065 | 1066 | [class^="resource__cell"] { 1067 | padding-left: 1rem; 1068 | padding-right: 1rem; 1069 | padding-top: 0.5rem; 1070 | padding-bottom: 0.5rem; 1071 | height: 5rem; 1072 | position: relative; 1073 | } 1074 | 1075 | .resource__cell:not(:first-child) .cell__contents { 1076 | overflow-y: auto; 1077 | } 1078 | 1079 | .cell__contents { 1080 | display: flex; 1081 | flex-direction: column; 1082 | height: 100%; 1083 | width: 100%; 1084 | justify-content: center; 1085 | align-items: safe center; 1086 | } 1087 | 1088 | .resource__menu--drop svg { 1089 | width: 1.25rem; 1090 | height: 1.25rem; 1091 | } 1092 | 1093 | .cell__copy { 1094 | cursor: pointer; 1095 | } 1096 | 1097 | .resource__form { 1098 | border-radius: 0.25rem; 1099 | border-collapse: collapse; 1100 | border-width: 1px; 1101 | --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 1102 | --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); 1103 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1104 | padding: 0.5rem; 1105 | margin: 0.25rem; 1106 | width: 100%; 1107 | } 1108 | 1109 | .form__actions { 1110 | display: flex; 1111 | justify-content: flex-end; 1112 | } 1113 | 1114 | .form__actions > * { 1115 | margin-left: 0.25rem; 1116 | } 1117 | 1118 | .table__actions { 1119 | display: flex; 1120 | } 1121 | 1122 | .table__actions * { 1123 | margin-right: 0.5rem; 1124 | } 1125 | 1126 | .embed__sort, .embed__drop { 1127 | display: none; 1128 | } 1129 | 1130 | .embed__title { 1131 | margin-bottom: 0.5rem; 1132 | text-transform: uppercase; 1133 | font-weight: 700; 1134 | font-size: 1.125rem; 1135 | line-height: 1.75rem; 1136 | } 1137 | 1138 | .embed__group { 1139 | border-left-width: 1px; 1140 | border-style: dashed; 1141 | padding-bottom: 1.25rem; 1142 | position: relative; 1143 | margin-left: 0.75rem; 1144 | } 1145 | 1146 | .embed__group .button__add { 1147 | position: absolute; 1148 | bottom: -0.5rem; 1149 | left: -0.75rem; 1150 | } 1151 | 1152 | .embed__item > .button__remove { 1153 | position: absolute; 1154 | top: -0.5rem; 1155 | left: -0.5rem; 1156 | } 1157 | 1158 | .embed__item > .button__up { 1159 | position: absolute; 1160 | top: -0.5rem; 1161 | right: -0.5rem; 1162 | } 1163 | 1164 | .embed__item > .button__down { 1165 | position: absolute; 1166 | bottom: -0.5rem; 1167 | right: -0.5rem; 1168 | } 1169 | 1170 | .embed__item { 1171 | position: relative; 1172 | border-width: 1px; 1173 | border-style: dotted; 1174 | margin-left: 1.25rem; 1175 | margin-top: 0.75rem; 1176 | } 1177 | 1178 | .embed__item > div { 1179 | flex-direction: column; 1180 | padding: 0.75rem; 1181 | flex-grow: 1; 1182 | } 1183 | 1184 | .field__group { 1185 | display: flex; 1186 | flex-direction: column; 1187 | margin-bottom: 1rem; 1188 | } 1189 | 1190 | .field__group--disabled { 1191 | margin-bottom: 1rem; 1192 | display: flex; 1193 | flex-direction: column; 1194 | opacity: 0.3; 1195 | } 1196 | 1197 | .field__label { 1198 | margin-bottom: 0.5rem; 1199 | text-transform: uppercase; 1200 | font-weight: 700; 1201 | font-size: 1.125rem; 1202 | line-height: 1.75rem; 1203 | } 1204 | 1205 | .checkbox__group input { 1206 | --tw-scale-x: 1.5; 1207 | --tw-scale-y: 1.5; 1208 | transform: var(--tw-transform); 1209 | } 1210 | 1211 | .checkbox__group label { 1212 | margin-left: 0.25rem; 1213 | } 1214 | 1215 | .checkbox__group { 1216 | display: grid; 1217 | grid-template-columns: auto minmax(0, 1fr); 1218 | gap: 0.25rem; 1219 | justify-items: start; 1220 | margin-left: 0.75rem; 1221 | } 1222 | 1223 | .field__array--group { 1224 | position: relative; 1225 | padding-bottom: 1.5rem; 1226 | } 1227 | 1228 | .field__array--group a.button__add { 1229 | position: absolute; 1230 | left: -0px; 1231 | bottom: -0.25rem; 1232 | } 1233 | 1234 | .field__array--group > div { 1235 | display: flex; 1236 | align-items: center; 1237 | margin-bottom: 0.5rem; 1238 | } 1239 | 1240 | .field__array--group > div > a { 1241 | flex-shrink: 0; 1242 | margin-right: 0.5rem; 1243 | } 1244 | 1245 | .field__array--group input { 1246 | margin-bottom: 0px; 1247 | } 1248 | 1249 | .field__map--group > div { 1250 | position: relative; 1251 | padding-bottom: 1.5rem; 1252 | } 1253 | 1254 | .field__map--group a.button__remove { 1255 | flex-shrink: 0; 1256 | margin-right: 0.5rem; 1257 | } 1258 | 1259 | .field__map--group a.button__add { 1260 | position: absolute; 1261 | left: 0px; 1262 | bottom: 0px; 1263 | } 1264 | 1265 | .field__map--row { 1266 | display: flex; 1267 | align-items: center; 1268 | margin-bottom: 0.5rem; 1269 | } 1270 | 1271 | .field__map--row textarea { 1272 | margin-bottom: 0px; 1273 | margin-right: 0.5rem; 1274 | width: 25%; 1275 | } 1276 | 1277 | .toast__container { 1278 | position: fixed; 1279 | width: 12rem; 1280 | z-index: 40; 1281 | border-radius: 0.25rem; 1282 | padding: 0.25rem; 1283 | text-align: center; 1284 | border-width: 1px; 1285 | bottom: 1.25rem; 1286 | text-overflow: clip; 1287 | overflow: hidden; 1288 | left: 50%; 1289 | top: auto !important; 1290 | transform: translate(-50%, -50%) !important; 1291 | } 1292 | 1293 | [class^="toast__container"]:empty { 1294 | display: none; 1295 | } 1296 | 1297 | .toast__container--info { 1298 | left: 50%; 1299 | position: fixed; 1300 | bottom: 1.25rem; 1301 | z-index: 40; 1302 | width: 12rem; 1303 | overflow: hidden; 1304 | text-overflow: clip; 1305 | border-radius: 0.25rem; 1306 | border-width: 1px; 1307 | padding: 0.25rem; 1308 | text-align: center; 1309 | top: auto !important; 1310 | transform: translate(-50%, -50%) !important; 1311 | } 1312 | 1313 | .toast__container--error { 1314 | left: 50%; 1315 | position: fixed; 1316 | bottom: 1.25rem; 1317 | z-index: 40; 1318 | width: 12rem; 1319 | overflow: hidden; 1320 | text-overflow: clip; 1321 | border-radius: 0.25rem; 1322 | border-width: 1px; 1323 | padding: 0.25rem; 1324 | text-align: center; 1325 | top: auto !important; 1326 | transform: translate(-50%, -50%) !important; 1327 | } 1328 | 1329 | .toast__container--success { 1330 | left: 50%; 1331 | position: fixed; 1332 | bottom: 1.25rem; 1333 | z-index: 40; 1334 | width: 12rem; 1335 | overflow: hidden; 1336 | text-overflow: clip; 1337 | border-radius: 0.25rem; 1338 | border-width: 1px; 1339 | padding: 0.25rem; 1340 | text-align: center; 1341 | top: auto !important; 1342 | transform: translate(-50%, -50%) !important; 1343 | } 1344 | 1345 | .mb-2 { 1346 | margin-bottom: 0.5rem; 1347 | } 1348 | 1349 | .flex { 1350 | display: flex; 1351 | } 1352 | 1353 | .table { 1354 | display: table; 1355 | } 1356 | 1357 | .contents { 1358 | display: contents; 1359 | } 1360 | 1361 | .hidden { 1362 | display: none; 1363 | } 1364 | 1365 | .h-full { 1366 | height: 100%; 1367 | } 1368 | 1369 | .h-6 { 1370 | height: 1.5rem; 1371 | } 1372 | 1373 | .h-5 { 1374 | height: 1.25rem; 1375 | } 1376 | 1377 | .w-1\/2 { 1378 | width: 50%; 1379 | } 1380 | 1381 | .w-6 { 1382 | width: 1.5rem; 1383 | } 1384 | 1385 | .w-full { 1386 | width: 100%; 1387 | } 1388 | 1389 | .w-5 { 1390 | width: 1.25rem; 1391 | } 1392 | 1393 | .transform { 1394 | transform: var(--tw-transform); 1395 | } 1396 | 1397 | .select-all { 1398 | -webkit-user-select: all; 1399 | -moz-user-select: all; 1400 | user-select: all; 1401 | } 1402 | 1403 | .items-center { 1404 | align-items: center; 1405 | } 1406 | 1407 | .justify-center { 1408 | justify-content: center; 1409 | } 1410 | 1411 | .rounded-lg { 1412 | border-radius: 0.5rem; 1413 | } 1414 | 1415 | .border-2 { 1416 | border-width: 2px; 1417 | } 1418 | 1419 | .border { 1420 | border-width: 1px; 1421 | } 1422 | 1423 | .border-l { 1424 | border-left-width: 1px; 1425 | } 1426 | 1427 | .p-2 { 1428 | padding: 0.5rem; 1429 | } 1430 | 1431 | .px-2 { 1432 | padding-left: 0.5rem; 1433 | padding-right: 0.5rem; 1434 | } 1435 | 1436 | .text-right { 1437 | text-align: right; 1438 | } 1439 | 1440 | .filter { 1441 | filter: var(--tw-filter); 1442 | } 1443 | 1444 | .transition { 1445 | transition-property: color, background-color, border-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-text-decoration-color, -webkit-backdrop-filter; 1446 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; 1447 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-text-decoration-color, -webkit-backdrop-filter; 1448 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 1449 | transition-duration: 150ms; 1450 | } 1451 | 1452 | input[type="date"], input[type="number"] { 1453 | width: 8rem; 1454 | } 1455 | 1456 | .cell__icons { 1457 | display: none; 1458 | position: absolute; 1459 | bottom: 0px; 1460 | right: 0px; 1461 | margin: 0.25rem; 1462 | } 1463 | 1464 | .cell__icons div { 1465 | cursor: pointer; 1466 | margin-left: 0.25rem; 1467 | margin-right: 0.25rem; 1468 | } 1469 | 1470 | [class^="resource__cell"]:hover .cell__icons { 1471 | display: flex; 1472 | } 1473 | 1474 | .resource__cell svg { 1475 | width: 1.5rem; 1476 | height: 1.5rem; 1477 | } 1478 | 1479 | .field__assoc--link { 1480 | line-height: 2.25rem; 1481 | -webkit-text-decoration-line: underline; 1482 | text-decoration-line: underline; 1483 | vertical-align: bottom; 1484 | } 1485 | 1486 | .field__assoc--link:hover::after { 1487 | line-height: 2.25rem; 1488 | display: inline-block; 1489 | content: " "; 1490 | background-image: url("data:image/svg+xml,"); 1491 | width: 20px; 1492 | height: 20px; 1493 | margin-left: 0.5rem; 1494 | } 1495 | --------------------------------------------------------------------------------