├── .formatter.exs
├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .tool-versions
├── .vscode
└── tasks.json
├── LICENSE
├── README.md
├── lib
├── ash_table.ex
└── ash_table
│ ├── table.ex
│ ├── table.html.heex
│ └── table_helpers.ex
├── mix.exs
├── mix.lock
├── test
├── ash_table_test.exs
└── test_helper.exs
└── test_bed
├── .formatter.exs
├── .gitignore
├── README.md
├── assets
├── css
│ └── app.css
├── js
│ └── app.js
├── tailwind.config.js
└── vendor
│ └── topbar.js
├── config
├── config.exs
├── dev.exs
├── prod.exs
├── runtime.exs
└── test.exs
├── lib
├── test_bed.ex
├── test_bed
│ ├── application.ex
│ ├── blog.ex
│ ├── blog
│ │ ├── author.ex
│ │ └── post.ex
│ ├── customers.ex
│ ├── customers
│ │ └── customer.ex
│ ├── factory.ex
│ ├── mailer.ex
│ └── repo.ex
├── test_bed_web.ex
└── test_bed_web
│ ├── components
│ ├── core_components.ex
│ ├── layouts.ex
│ └── layouts
│ │ ├── app.html.heex
│ │ └── root.html.heex
│ ├── controllers
│ ├── error_html.ex
│ ├── error_json.ex
│ ├── page_controller.ex
│ ├── page_html.ex
│ └── page_html
│ │ └── home.html.heex
│ ├── endpoint.ex
│ ├── gettext.ex
│ ├── live
│ ├── customers_live
│ │ └── index.ex
│ └── posts_live
│ │ └── index.ex
│ ├── router.ex
│ └── telemetry.ex
├── mix.exs
├── mix.lock
├── priv
├── gettext
│ ├── en
│ │ └── LC_MESSAGES
│ │ │ └── errors.po
│ └── errors.pot
├── repo
│ ├── migrations
│ │ ├── .formatter.exs
│ │ ├── 20240629155735_install_3_extensions.exs
│ │ ├── 20240629155736_initial_migration.exs
│ │ ├── 20240629173304_add_author.exs
│ │ ├── 20240701190158_add_customer.exs
│ │ └── 20240701190257_add_customer_tenant.exs
│ └── seeds.exs
├── resource_snapshots
│ └── repo
│ │ ├── authors
│ │ └── 20240629173304.json
│ │ ├── customers
│ │ ├── 20240701190158.json
│ │ └── 20240701190257.json
│ │ ├── extensions.json
│ │ └── posts
│ │ ├── 20240629155736.json
│ │ └── 20240629173304.json
└── static
│ ├── favicon.ico
│ ├── images
│ └── logo.svg
│ └── robots.txt
└── test
├── support
├── conn_case.ex
└── data_case.ex
├── test_bed_web
├── controllers
│ ├── error_html_test.exs
│ ├── error_json_test.exs
│ └── page_controller_test.exs
└── live
│ ├── customers_live
│ └── index_test.exs
│ └── posts_live
│ └── index_test.exs
└── test_helper.exs
/.formatter.exs:
--------------------------------------------------------------------------------
1 | # Used by "mix format"
2 | [
3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{heex,ex,exs}", "test_bed/{config,lib,test}/**/*.{heex,ex,exs}"]
4 | ]
5 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: push
4 |
5 | jobs:
6 | test:
7 | name: Build and run tests
8 | runs-on: ubuntu-20.04
9 |
10 | services:
11 | db:
12 | image: postgres
13 | ports: ["5432:5432"]
14 | env:
15 | POSTGRES_USER: postgres
16 | POSTGRES_PASSWORD: postgres
17 | POSTRGES_DB: test_bed_test
18 | options: >-
19 | --health-cmd pg_isready
20 | --health-interval 10s
21 | --health-timeout 5s
22 | --health-retries 5
23 |
24 | steps:
25 | - name: Checkout project
26 | uses: actions/checkout@v3
27 | - name: Set up Elixir
28 | uses: erlef/setup-beam@v1
29 | with:
30 | otp-version: '26.2.1' # Define the OTP version [required]
31 | elixir-version: '1.16.3-otp-26' # Define the elixir version [required]
32 | - name: Restore dependencies cache
33 | uses: actions/cache@v2
34 | with:
35 | path: ash_table/deps
36 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
37 | restore-keys: ${{ runner.os }}-mix-
38 | - uses: actions/cache@v1
39 | name: Cache build
40 | with:
41 | path: ash_table/_build
42 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
43 | - name: Install dependencies
44 | run: mix deps.get
45 | - name: Install testbed dependencies
46 | run: mix deps.get
47 | working-directory: ./test_bed
48 | - name: Run elixir tests
49 | run: mix test
50 | working-directory: ./test_bed
51 |
--------------------------------------------------------------------------------
/.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 | ash_table-*.tar
24 |
25 | # Temporary files, for example, from tests.
26 | /tmp/
27 |
--------------------------------------------------------------------------------
/.tool-versions:
--------------------------------------------------------------------------------
1 | elixir 1.16.3-otp-26
2 | erlang 26.0.2
3 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "label": "mix format",
6 | "type": "shell",
7 | "command": "mix",
8 | "args": [
9 | "format"
10 | ],
11 | "problemMatcher": "$mixTestFailure"
12 | },
13 | {
14 | "label": "mix test",
15 | "type": "shell",
16 | "command": "mix",
17 | "args": [
18 | "test",
19 | "--color",
20 | "--warnings-as-errors"
21 | ],
22 | "options": {
23 | "cwd": "${workspaceRoot}/test_bed",
24 | "requireFiles": [
25 | "test/**/test_helper.exs",
26 | "test/**/*_test.exs"
27 | ]
28 | },
29 | "problemMatcher": "$mixTestFailure"
30 | },
31 | {
32 | "label": "mix test failed",
33 | "type": "shell",
34 | "command": "mix",
35 | "args": [
36 | "test",
37 | "--color",
38 | "--failed",
39 | "--trace"
40 | ],
41 | "options": {
42 | "cwd": "${workspaceRoot}/test_bed",
43 | "requireFiles": [
44 | "test/**/test_helper.exs",
45 | "test/**/*_test.exs"
46 | ]
47 | },
48 | "problemMatcher": "$mixTestFailure"
49 | },
50 | {
51 | "label": "mix test file",
52 | "type": "shell",
53 | "command": "mix",
54 | "args": [
55 | "test",
56 | "${relativeFile}",
57 | "--color",
58 | "--trace"
59 | ],
60 | "options": {
61 | "cwd": "${workspaceRoot}/test_bed",
62 | "requireFiles": [
63 | "test/**/test_helper.exs",
64 | "test/**/*_test.exs"
65 | ]
66 | },
67 | "problemMatcher": "$mixTestFailure"
68 | },
69 | {
70 | "label": "mix test focused",
71 | "type": "shell",
72 | "command": "mix",
73 | "args": [
74 | "test",
75 | "${relativeFile}:${lineNumber}",
76 | "--color",
77 | "--trace"
78 | ],
79 | "options": {
80 | "cwd": "${workspaceRoot}/test_bed",
81 | "requireFiles": [
82 | "test/**/test_helper.exs",
83 | "test/**/*_test.exs"
84 | ]
85 | },
86 | "problemMatcher": "$mixTestFailure",
87 | "group": {
88 | "kind": "test",
89 | "isDefault": true
90 | }
91 | },
92 | {
93 | "label": "Initial Setup",
94 | "type": "process",
95 | "command": "mix",
96 | "args": [
97 | "setup"
98 | ],
99 | "options": {
100 | "cwd": "${workspaceRoot}"
101 | },
102 | "problemMatcher": [
103 | "$mixCompileError"
104 | ]
105 | },
106 | {
107 | "label": "mix compile test",
108 | "type": "process",
109 | "command": "mix",
110 | "args": [
111 | "test",
112 | "--exclude",
113 | "test",
114 | "--warnings-as-errors"
115 | ],
116 | "options": {
117 | "cwd": "${workspaceRoot}"
118 | },
119 | "problemMatcher": [
120 | "$mixCompileWarning",
121 | "$mixCompileError"
122 | ],
123 | "group": {
124 | "kind": "build"
125 | }
126 | },
127 | {
128 | "label": "mix compile",
129 | "type": "process",
130 | "command": "mix",
131 | "args": [
132 | "compile",
133 | "--all-warnings"
134 | ],
135 | "options": {
136 | "cwd": "${workspaceRoot}"
137 | },
138 | "problemMatcher": [
139 | "$mixCompileWarning",
140 | "$mixCompileError"
141 | ],
142 | "group": {
143 | "kind": "build",
144 | "isDefault": true
145 | }
146 | }
147 | ]
148 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2024 Christopher Nelson
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
13 | all 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
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # AshTable
2 |
3 | This is a sortable, paginated table component for Ash resources or queries
4 |
5 | ## Status
6 |
7 | - [X] Sort by attribute
8 | - [X] Sort by relation (using function passed to column)
9 | - [X] Pagination
10 |
11 | ## Usage
12 |
13 | Here's an example taken from the test_bed:
14 |
15 | ```elixir
16 | defmodule TestBedWeb.PostsLive.Index do
17 | use TestBedWeb, :live_view
18 |
19 | def render(assigns) do
20 | ~H"""
21 | <.live_component id="posts_table" limit={10} offset={0} sort={{"id", :asc}} module={AshTable.Table} query={TestBed.Blog.Post}>
22 | <:col :let={post} label="Id" sort_key="id"><%= post.id %>
23 | <:col :let={post} label="Title" sort_key="title">
24 | <%= post.title %>
25 |
26 | <:col :let={post} label="Author" apply_sort={&sort_by_author/2} sort_key="author.name">
27 | <%= if post.author, do: post.author.name %>
28 |
29 |
30 | """
31 | end
32 |
33 | require Ash.Sort
34 |
35 | defp sort_by_author(query, direction) do
36 | Ash.Query.sort(query, {Ash.Sort.expr_sort(author.name), direction})
37 | end
38 |
39 | end
40 | ```
41 |
42 | In this case the `TestBed.Blog.Post` resource has a title, content, and belongs to Author which has a name. The table is paginated, and sortable by Title and Author name.
43 |
44 | Note use of the `apply_sort` being passed into the `:col`. This is needed for sorting by related properties due to how Ash works, or til I better understand it and find a simpler way :) The `sort_key` assign is still required so that the correct column is identified when the sort event fires.
45 |
46 | ## Running the test_bed example project
47 |
48 | ```
49 | cd test_bed
50 | mix deps.get
51 | mix ash.setup
52 | mix phx.server
53 | ```
54 |
55 | ## Future
56 |
57 | Currently there is very little styling. The goal would be to allow a good default, but great flexiblity. Harcoding a dependency on Tailwind or other css frameworks is not desirable, but allowing the user to decide to use one would be great.
58 |
--------------------------------------------------------------------------------
/lib/ash_table.ex:
--------------------------------------------------------------------------------
1 | defmodule AshTable do
2 | @moduledoc """
3 | Documentation for `AshTable`.
4 | """
5 |
6 | @doc """
7 | Hello world.
8 |
9 | ## Examples
10 |
11 | iex> AshTable.hello()
12 | :world
13 |
14 | """
15 | def hello do
16 | :world
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/lib/ash_table/table.ex:
--------------------------------------------------------------------------------
1 | defmodule AshTable.Table do
2 | use Phoenix.LiveComponent
3 |
4 | alias AshTable.TableHelpers
5 |
6 | @moduledoc """
7 | Generic sortable table component
8 |
9 | Expects the following parameters as assigns:
10 |
11 | * `id` - necessary, as this is a stateful LiveView component
12 | * `query` - An Ash Query or Resource module
13 | * `sort` (optional) - a `t:sort/0` specifying the initial sort direction
14 | * `limit` - page size
15 | * `offset` - initial offset for pagination
16 | * `col` columns
17 | * attribute - the field this column displays, used to sort
18 | * apply_sort - optional arity 2 function which takes query, direction as args
19 | * `caption` (optional)
20 | * `read_options` - an options keyword list of options that will be passed into `Ash.read` when data is fetched.
21 | This allows for specifying `:tenant`, `:actor`, etc.
22 |
23 | """
24 |
25 | @assigns [
26 | :id,
27 | :sort,
28 | :query,
29 | :col,
30 | :offset,
31 | :limit,
32 | :read_options
33 | ]
34 |
35 | @default_assigns %{
36 | limit: 10,
37 | offset: 0,
38 | read_options: []
39 | }
40 |
41 | @type sort :: {atom | nil, :asc | :desc}
42 |
43 | @impl true
44 | def mount(socket) do
45 | socket
46 | |> assign(sort: {nil, :asc})
47 | |> then(&{:ok, &1})
48 | end
49 |
50 | @impl true
51 | def update(assigns, socket) do
52 | socket
53 | |> assign(apply_defaults(assigns))
54 | |> assign(:query, assigns.query)
55 | |> fetch_data()
56 | |> then(&{:ok, &1})
57 | end
58 |
59 | defp apply_defaults(assigns) do
60 | @default_assigns |> Map.merge(Map.take(assigns, @assigns))
61 | end
62 |
63 | defp fetch_data(
64 | %{
65 | assigns: %{
66 | query: query,
67 | sort: sort,
68 | col: columns,
69 | limit: limit,
70 | offset: offset,
71 | read_options: read_options
72 | }
73 | } = socket
74 | ) do
75 | results =
76 | query
77 | |> apply_sort(sort, columns)
78 | |> Ash.read!(Keyword.merge(read_options, page: [limit: limit, offset: offset]))
79 |
80 | assign(socket, :results, results)
81 | end
82 |
83 | defp rows_from(%Ash.Page.Offset{results: results}), do: results
84 |
85 | defp apply_sort(query, {sort_key, direction}, columns) do
86 | col = columns |> Enum.find(&(&1[:sort_key] == sort_key))
87 |
88 | case col do
89 | %{apply_sort: apply_sort} when is_function(apply_sort) -> apply_sort.(query, direction)
90 | _ -> Ash.Query.sort(query, {String.to_existing_atom(sort_key), direction})
91 | end
92 | end
93 |
94 | @impl true
95 | def handle_event(
96 | "sort",
97 | %{"column" => column, "direction" => direction} = _params,
98 | socket
99 | ) do
100 | direction = String.to_existing_atom(direction)
101 | sort = {column, direction}
102 |
103 | socket
104 | |> assign(sort: sort)
105 | |> fetch_data()
106 | |> then(&{:noreply, &1})
107 | end
108 |
109 | def handle_event("set_page", %{"offset" => offset}, socket) do
110 | socket
111 | |> assign(offset: String.to_integer(offset))
112 | |> fetch_data()
113 | |> then(&{:noreply, &1})
114 | end
115 |
116 | def sort_class(column_key, {sort_key, direction}) do
117 | if String.to_existing_atom(column_key) == sort_key do
118 | Atom.to_string(direction)
119 | else
120 | "none"
121 | end
122 | end
123 |
124 | def sort_direction(column_key, sort) when is_binary(column_key) do
125 | column_key
126 | |> String.to_existing_atom()
127 | |> sort_direction(sort)
128 | end
129 |
130 | def sort_direction(column_key, {column_key, direction}), do: toggle_direction(direction)
131 | def sort_direction(_, _), do: :asc
132 |
133 | def toggle_direction(:asc), do: :desc
134 | def toggle_direction(:desc), do: :asc
135 |
136 | def sort_normalized_keys(keys) do
137 | fn obj ->
138 | keys |> Enum.map(&(obj[&1] || "")) |> Enum.map(&String.downcase/1) |> List.to_tuple()
139 | end
140 | end
141 |
142 | def noreply(term) do
143 | {:noreply, term}
144 | end
145 |
146 | def ok(term) do
147 | {:ok, term}
148 | end
149 | end
150 |
--------------------------------------------------------------------------------
/lib/ash_table/table.html.heex:
--------------------------------------------------------------------------------
1 |
27 | //
28 | plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])),
29 | plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
30 | plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])),
31 | plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])),
32 |
33 | // Embeds Heroicons (https://heroicons.com) into your app.css bundle
34 | // See your `CoreComponents.icon/1` for more information.
35 | //
36 | plugin(function({matchComponents, theme}) {
37 | let iconsDir = path.join(__dirname, "../deps/heroicons/optimized")
38 | let values = {}
39 | let icons = [
40 | ["", "/24/outline"],
41 | ["-solid", "/24/solid"],
42 | ["-mini", "/20/solid"],
43 | ["-micro", "/16/solid"]
44 | ]
45 | icons.forEach(([suffix, dir]) => {
46 | fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
47 | let name = path.basename(file, ".svg") + suffix
48 | values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
49 | })
50 | })
51 | matchComponents({
52 | "hero": ({name, fullPath}) => {
53 | let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
54 | let size = theme("spacing.6")
55 | if (name.endsWith("-mini")) {
56 | size = theme("spacing.5")
57 | } else if (name.endsWith("-micro")) {
58 | size = theme("spacing.4")
59 | }
60 | return {
61 | [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
62 | "-webkit-mask": `var(--hero-${name})`,
63 | "mask": `var(--hero-${name})`,
64 | "mask-repeat": "no-repeat",
65 | "background-color": "currentColor",
66 | "vertical-align": "middle",
67 | "display": "inline-block",
68 | "width": size,
69 | "height": size
70 | }
71 | }
72 | }, {values})
73 | })
74 | ]
75 | }
76 |
--------------------------------------------------------------------------------
/test_bed/assets/vendor/topbar.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @license MIT
3 | * topbar 2.0.0, 2023-02-04
4 | * https://buunguyen.github.io/topbar
5 | * Copyright (c) 2021 Buu Nguyen
6 | */
7 | (function (window, document) {
8 | "use strict";
9 |
10 | // https://gist.github.com/paulirish/1579671
11 | (function () {
12 | var lastTime = 0;
13 | var vendors = ["ms", "moz", "webkit", "o"];
14 | for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
15 | window.requestAnimationFrame =
16 | window[vendors[x] + "RequestAnimationFrame"];
17 | window.cancelAnimationFrame =
18 | window[vendors[x] + "CancelAnimationFrame"] ||
19 | window[vendors[x] + "CancelRequestAnimationFrame"];
20 | }
21 | if (!window.requestAnimationFrame)
22 | window.requestAnimationFrame = function (callback, element) {
23 | var currTime = new Date().getTime();
24 | var timeToCall = Math.max(0, 16 - (currTime - lastTime));
25 | var id = window.setTimeout(function () {
26 | callback(currTime + timeToCall);
27 | }, timeToCall);
28 | lastTime = currTime + timeToCall;
29 | return id;
30 | };
31 | if (!window.cancelAnimationFrame)
32 | window.cancelAnimationFrame = function (id) {
33 | clearTimeout(id);
34 | };
35 | })();
36 |
37 | var canvas,
38 | currentProgress,
39 | showing,
40 | progressTimerId = null,
41 | fadeTimerId = null,
42 | delayTimerId = null,
43 | addEvent = function (elem, type, handler) {
44 | if (elem.addEventListener) elem.addEventListener(type, handler, false);
45 | else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
46 | else elem["on" + type] = handler;
47 | },
48 | options = {
49 | autoRun: true,
50 | barThickness: 3,
51 | barColors: {
52 | 0: "rgba(26, 188, 156, .9)",
53 | ".25": "rgba(52, 152, 219, .9)",
54 | ".50": "rgba(241, 196, 15, .9)",
55 | ".75": "rgba(230, 126, 34, .9)",
56 | "1.0": "rgba(211, 84, 0, .9)",
57 | },
58 | shadowBlur: 10,
59 | shadowColor: "rgba(0, 0, 0, .6)",
60 | className: null,
61 | },
62 | repaint = function () {
63 | canvas.width = window.innerWidth;
64 | canvas.height = options.barThickness * 5; // need space for shadow
65 |
66 | var ctx = canvas.getContext("2d");
67 | ctx.shadowBlur = options.shadowBlur;
68 | ctx.shadowColor = options.shadowColor;
69 |
70 | var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
71 | for (var stop in options.barColors)
72 | lineGradient.addColorStop(stop, options.barColors[stop]);
73 | ctx.lineWidth = options.barThickness;
74 | ctx.beginPath();
75 | ctx.moveTo(0, options.barThickness / 2);
76 | ctx.lineTo(
77 | Math.ceil(currentProgress * canvas.width),
78 | options.barThickness / 2
79 | );
80 | ctx.strokeStyle = lineGradient;
81 | ctx.stroke();
82 | },
83 | createCanvas = function () {
84 | canvas = document.createElement("canvas");
85 | var style = canvas.style;
86 | style.position = "fixed";
87 | style.top = style.left = style.right = style.margin = style.padding = 0;
88 | style.zIndex = 100001;
89 | style.display = "none";
90 | if (options.className) canvas.classList.add(options.className);
91 | document.body.appendChild(canvas);
92 | addEvent(window, "resize", repaint);
93 | },
94 | topbar = {
95 | config: function (opts) {
96 | for (var key in opts)
97 | if (options.hasOwnProperty(key)) options[key] = opts[key];
98 | },
99 | show: function (delay) {
100 | if (showing) return;
101 | if (delay) {
102 | if (delayTimerId) return;
103 | delayTimerId = setTimeout(() => topbar.show(), delay);
104 | } else {
105 | showing = true;
106 | if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
107 | if (!canvas) createCanvas();
108 | canvas.style.opacity = 1;
109 | canvas.style.display = "block";
110 | topbar.progress(0);
111 | if (options.autoRun) {
112 | (function loop() {
113 | progressTimerId = window.requestAnimationFrame(loop);
114 | topbar.progress(
115 | "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
116 | );
117 | })();
118 | }
119 | }
120 | },
121 | progress: function (to) {
122 | if (typeof to === "undefined") return currentProgress;
123 | if (typeof to === "string") {
124 | to =
125 | (to.indexOf("+") >= 0 || to.indexOf("-") >= 0
126 | ? currentProgress
127 | : 0) + parseFloat(to);
128 | }
129 | currentProgress = to > 1 ? 1 : to;
130 | repaint();
131 | return currentProgress;
132 | },
133 | hide: function () {
134 | clearTimeout(delayTimerId);
135 | delayTimerId = null;
136 | if (!showing) return;
137 | showing = false;
138 | if (progressTimerId != null) {
139 | window.cancelAnimationFrame(progressTimerId);
140 | progressTimerId = null;
141 | }
142 | (function loop() {
143 | if (topbar.progress("+.1") >= 1) {
144 | canvas.style.opacity -= 0.05;
145 | if (canvas.style.opacity <= 0.05) {
146 | canvas.style.display = "none";
147 | fadeTimerId = null;
148 | return;
149 | }
150 | }
151 | fadeTimerId = window.requestAnimationFrame(loop);
152 | })();
153 | },
154 | };
155 |
156 | if (typeof module === "object" && typeof module.exports === "object") {
157 | module.exports = topbar;
158 | } else if (typeof define === "function" && define.amd) {
159 | define(function () {
160 | return topbar;
161 | });
162 | } else {
163 | this.topbar = topbar;
164 | }
165 | }.call(this, window, document));
166 |
--------------------------------------------------------------------------------
/test_bed/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Config module.
3 | #
4 | # This configuration file is loaded before any dependency and
5 | # is restricted to this project.
6 |
7 | # General application configuration
8 | import Config
9 |
10 | config :test_bed,
11 | ecto_repos: [TestBed.Repo],
12 | generators: [timestamp_type: :utc_datetime]
13 |
14 | # Configures the endpoint
15 | config :test_bed, TestBedWeb.Endpoint,
16 | url: [host: "localhost"],
17 | adapter: Bandit.PhoenixAdapter,
18 | render_errors: [
19 | formats: [html: TestBedWeb.ErrorHTML, json: TestBedWeb.ErrorJSON],
20 | layout: false
21 | ],
22 | pubsub_server: TestBed.PubSub,
23 | live_view: [signing_salt: "oMtOzrbs"]
24 |
25 | config :test_bed,
26 | ash_domains: [TestBed.Blog, TestBed.Customers]
27 |
28 | # Configures the mailer
29 | #
30 | # By default it uses the "Local" adapter which stores the emails
31 | # locally. You can see the emails in your browser, at "/dev/mailbox".
32 | #
33 | # For production it's recommended to configure a different adapter
34 | # at the `config/runtime.exs`.
35 | config :test_bed, TestBed.Mailer, adapter: Swoosh.Adapters.Local
36 |
37 | # Configure esbuild (the version is required)
38 | config :esbuild,
39 | version: "0.17.11",
40 | test_bed: [
41 | args:
42 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
43 | cd: Path.expand("../assets", __DIR__),
44 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
45 | ]
46 |
47 | # Configure tailwind (the version is required)
48 | config :tailwind,
49 | version: "3.4.0",
50 | test_bed: [
51 | args: ~w(
52 | --config=tailwind.config.js
53 | --input=css/app.css
54 | --output=../priv/static/assets/app.css
55 | ),
56 | cd: Path.expand("../assets", __DIR__)
57 | ]
58 |
59 | # Configures Elixir's Logger
60 | config :logger, :console,
61 | format: "$time $metadata[$level] $message\n",
62 | metadata: [:request_id]
63 |
64 | # Use Jason for JSON parsing in Phoenix
65 | config :phoenix, :json_library, Jason
66 |
67 | # Import environment specific config. This must remain at the bottom
68 | # of this file so it overrides the configuration defined above.
69 | import_config "#{config_env()}.exs"
70 |
--------------------------------------------------------------------------------
/test_bed/config/dev.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Configure your database
4 | config :test_bed, TestBed.Repo,
5 | username: "postgres",
6 | password: "postgres",
7 | hostname: "localhost",
8 | database: "test_bed_dev",
9 | stacktrace: true,
10 | show_sensitive_data_on_connection_error: true,
11 | pool_size: 10
12 |
13 | # For development, we disable any cache and enable
14 | # debugging and code reloading.
15 | #
16 | # The watchers configuration can be used to run external
17 | # watchers to your application. For example, we can use it
18 | # to bundle .js and .css sources.
19 | config :test_bed, TestBedWeb.Endpoint,
20 | # Binding to loopback ipv4 address prevents access from other machines.
21 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
22 | http: [ip: {127, 0, 0, 1}, port: 4000],
23 | check_origin: false,
24 | code_reloader: true,
25 | debug_errors: true,
26 | secret_key_base: "VcxZjOXWllz952MBjItAnOZQxDTaVZQRtlZSpY+eZhuW3rsRrEaRah4FE1ihgTEh",
27 | watchers: [
28 | esbuild: {Esbuild, :install_and_run, [:test_bed, ~w(--sourcemap=inline --watch)]},
29 | tailwind: {Tailwind, :install_and_run, [:test_bed, ~w(--watch)]}
30 | ]
31 |
32 | # ## SSL Support
33 | #
34 | # In order to use HTTPS in development, a self-signed
35 | # certificate can be generated by running the following
36 | # Mix task:
37 | #
38 | # mix phx.gen.cert
39 | #
40 | # Run `mix help phx.gen.cert` for more information.
41 | #
42 | # The `http:` config above can be replaced with:
43 | #
44 | # https: [
45 | # port: 4001,
46 | # cipher_suite: :strong,
47 | # keyfile: "priv/cert/selfsigned_key.pem",
48 | # certfile: "priv/cert/selfsigned.pem"
49 | # ],
50 | #
51 | # If desired, both `http:` and `https:` keys can be
52 | # configured to run both http and https servers on
53 | # different ports.
54 |
55 | # Watch static and templates for browser reloading.
56 | config :test_bed, TestBedWeb.Endpoint,
57 | live_reload: [
58 | patterns: [
59 | ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
60 | ~r"priv/gettext/.*(po)$",
61 | ~r"lib/test_bed_web/(controllers|live|components)/.*(ex|heex)$"
62 | ]
63 | ]
64 |
65 | # Enable dev routes for dashboard and mailbox
66 | config :test_bed, dev_routes: true
67 |
68 | # Do not include metadata nor timestamps in development logs
69 | config :logger, :console, format: "[$level] $message\n"
70 |
71 | # Set a higher stacktrace during development. Avoid configuring such
72 | # in production as building large stacktraces may be expensive.
73 | config :phoenix, :stacktrace_depth, 20
74 |
75 | # Initialize plugs at runtime for faster development compilation
76 | config :phoenix, :plug_init_mode, :runtime
77 |
78 | config :phoenix_live_view,
79 | # Include HEEx debug annotations as HTML comments in rendered markup
80 | debug_heex_annotations: true,
81 | # Enable helpful, but potentially expensive runtime checks
82 | enable_expensive_runtime_checks: true
83 |
84 | # Disable swoosh api client as it is only required for production adapters.
85 | config :swoosh, :api_client, false
86 |
--------------------------------------------------------------------------------
/test_bed/config/prod.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Note we also include the path to a cache manifest
4 | # containing the digested version of static files. This
5 | # manifest is generated by the `mix assets.deploy` task,
6 | # which you should run after static files are built and
7 | # before starting your production server.
8 | config :test_bed, TestBedWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
9 |
10 | # Configures Swoosh API Client
11 | config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: TestBed.Finch
12 |
13 | # Disable Swoosh Local Memory Storage
14 | config :swoosh, local: false
15 |
16 | # Do not print debug messages in production
17 | config :logger, level: :info
18 |
19 | # Runtime production configuration, including reading
20 | # of environment variables, is done on config/runtime.exs.
21 |
--------------------------------------------------------------------------------
/test_bed/config/runtime.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # config/runtime.exs is executed for all environments, including
4 | # during releases. It is executed after compilation and before the
5 | # system starts, so it is typically used to load production configuration
6 | # and secrets from environment variables or elsewhere. Do not define
7 | # any compile-time configuration in here, as it won't be applied.
8 | # The block below contains prod specific runtime configuration.
9 |
10 | # ## Using releases
11 | #
12 | # If you use `mix release`, you need to explicitly enable the server
13 | # by passing the PHX_SERVER=true when you start it:
14 | #
15 | # PHX_SERVER=true bin/test_bed start
16 | #
17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
18 | # script that automatically sets the env var above.
19 | if System.get_env("PHX_SERVER") do
20 | config :test_bed, TestBedWeb.Endpoint, server: true
21 | end
22 |
23 | if config_env() == :prod do
24 | database_url =
25 | System.get_env("DATABASE_URL") ||
26 | raise """
27 | environment variable DATABASE_URL is missing.
28 | For example: ecto://USER:PASS@HOST/DATABASE
29 | """
30 |
31 | maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
32 |
33 | config :test_bed, TestBed.Repo,
34 | # ssl: true,
35 | url: database_url,
36 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
37 | socket_options: maybe_ipv6
38 |
39 | # The secret key base is used to sign/encrypt cookies and other secrets.
40 | # A default value is used in config/dev.exs and config/test.exs but you
41 | # want to use a different value for prod and you most likely don't want
42 | # to check this value into version control, so we use an environment
43 | # variable instead.
44 | secret_key_base =
45 | System.get_env("SECRET_KEY_BASE") ||
46 | raise """
47 | environment variable SECRET_KEY_BASE is missing.
48 | You can generate one by calling: mix phx.gen.secret
49 | """
50 |
51 | host = System.get_env("PHX_HOST") || "example.com"
52 | port = String.to_integer(System.get_env("PORT") || "4000")
53 |
54 | config :test_bed, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
55 |
56 | config :test_bed, TestBedWeb.Endpoint,
57 | url: [host: host, port: 443, scheme: "https"],
58 | http: [
59 | # Enable IPv6 and bind on all interfaces.
60 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
61 | # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
62 | # for details about using IPv6 vs IPv4 and loopback vs public addresses.
63 | ip: {0, 0, 0, 0, 0, 0, 0, 0},
64 | port: port
65 | ],
66 | secret_key_base: secret_key_base
67 |
68 | # ## SSL Support
69 | #
70 | # To get SSL working, you will need to add the `https` key
71 | # to your endpoint configuration:
72 | #
73 | # config :test_bed, TestBedWeb.Endpoint,
74 | # https: [
75 | # ...,
76 | # port: 443,
77 | # cipher_suite: :strong,
78 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
79 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
80 | # ]
81 | #
82 | # The `cipher_suite` is set to `:strong` to support only the
83 | # latest and more secure SSL ciphers. This means old browsers
84 | # and clients may not be supported. You can set it to
85 | # `:compatible` for wider support.
86 | #
87 | # `:keyfile` and `:certfile` expect an absolute path to the key
88 | # and cert in disk or a relative path inside priv, for example
89 | # "priv/ssl/server.key". For all supported SSL configuration
90 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
91 | #
92 | # We also recommend setting `force_ssl` in your config/prod.exs,
93 | # ensuring no data is ever sent via http, always redirecting to https:
94 | #
95 | # config :test_bed, TestBedWeb.Endpoint,
96 | # force_ssl: [hsts: true]
97 | #
98 | # Check `Plug.SSL` for all available options in `force_ssl`.
99 |
100 | # ## Configuring the mailer
101 | #
102 | # In production you need to configure the mailer to use a different adapter.
103 | # Also, you may need to configure the Swoosh API client of your choice if you
104 | # are not using SMTP. Here is an example of the configuration:
105 | #
106 | # config :test_bed, TestBed.Mailer,
107 | # adapter: Swoosh.Adapters.Mailgun,
108 | # api_key: System.get_env("MAILGUN_API_KEY"),
109 | # domain: System.get_env("MAILGUN_DOMAIN")
110 | #
111 | # For this example you need include a HTTP client required by Swoosh API client.
112 | # Swoosh supports Hackney and Finch out of the box:
113 | #
114 | # config :swoosh, :api_client, Swoosh.ApiClient.Hackney
115 | #
116 | # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
117 | end
118 |
--------------------------------------------------------------------------------
/test_bed/config/test.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Configure your database
4 | #
5 | # The MIX_TEST_PARTITION environment variable can be used
6 | # to provide built-in test partitioning in CI environment.
7 | # Run `mix help test` for more information.
8 | config :test_bed, TestBed.Repo,
9 | username: "postgres",
10 | password: "postgres",
11 | hostname: "localhost",
12 | database: "test_bed_test#{System.get_env("MIX_TEST_PARTITION")}",
13 | pool: Ecto.Adapters.SQL.Sandbox,
14 | pool_size: System.schedulers_online() * 2
15 |
16 | # We don't run a server during test. If one is required,
17 | # you can enable the server option below.
18 | config :test_bed, TestBedWeb.Endpoint,
19 | http: [ip: {127, 0, 0, 1}, port: 4002],
20 | secret_key_base: "ASMDSrPwYglSQK65JnBTHFehzKSH90M5j4Av2MTY1fFam9X2bGvhs7q3b+1BbSti",
21 | server: false
22 |
23 | # In test we don't send emails.
24 | config :test_bed, TestBed.Mailer, adapter: Swoosh.Adapters.Test
25 |
26 | # Disable swoosh api client as it is only required for production adapters.
27 | config :swoosh, :api_client, false
28 |
29 | # Print only warnings and errors during test
30 | config :logger, level: :warning
31 |
32 | # Initialize plugs at runtime for faster test compilation
33 | config :phoenix, :plug_init_mode, :runtime
34 |
35 | config :phoenix_live_view,
36 | # Enable helpful, but potentially expensive runtime checks
37 | enable_expensive_runtime_checks: true
38 |
--------------------------------------------------------------------------------
/test_bed/lib/test_bed.ex:
--------------------------------------------------------------------------------
1 | defmodule TestBed do
2 | @moduledoc """
3 | TestBed keeps the contexts that define your domain
4 | and business logic.
5 |
6 | Contexts are also responsible for managing your data, regardless
7 | if it comes from the database, an external API or others.
8 | """
9 | end
10 |
--------------------------------------------------------------------------------
/test_bed/lib/test_bed/application.ex:
--------------------------------------------------------------------------------
1 | defmodule TestBed.Application do
2 | # See https://hexdocs.pm/elixir/Application.html
3 | # for more information on OTP Applications
4 | @moduledoc false
5 |
6 | use Application
7 |
8 | @impl true
9 | def start(_type, _args) do
10 | children = [
11 | TestBedWeb.Telemetry,
12 | TestBed.Repo,
13 | {DNSCluster, query: Application.get_env(:test_bed, :dns_cluster_query) || :ignore},
14 | {Phoenix.PubSub, name: TestBed.PubSub},
15 | # Start the Finch HTTP client for sending emails
16 | {Finch, name: TestBed.Finch},
17 | # Start a worker by calling: TestBed.Worker.start_link(arg)
18 | # {TestBed.Worker, arg},
19 | # Start to serve requests, typically the last entry
20 | TestBedWeb.Endpoint
21 | ]
22 |
23 | # See https://hexdocs.pm/elixir/Supervisor.html
24 | # for other strategies and supported options
25 | opts = [strategy: :one_for_one, name: TestBed.Supervisor]
26 | Supervisor.start_link(children, opts)
27 | end
28 |
29 | # Tell Phoenix to update the endpoint configuration
30 | # whenever the application is updated.
31 | @impl true
32 | def config_change(changed, _new, removed) do
33 | TestBedWeb.Endpoint.config_change(changed, removed)
34 | :ok
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/test_bed/lib/test_bed/blog.ex:
--------------------------------------------------------------------------------
1 | defmodule TestBed.Blog do
2 | use Ash.Domain
3 |
4 | resources do
5 | resource TestBed.Blog.Post do
6 | # Define an interface for calling resource actions.
7 | define :create_post, action: :create
8 | define :list_posts, action: :read
9 | define :update_post, action: :update
10 | define :destroy_post, action: :destroy
11 | define :get_post, args: [:id], action: :by_id
12 | end
13 |
14 | resource TestBed.Blog.Author do
15 | # Define an interface for calling resource actions.
16 | define :create_author, action: :create
17 | define :list_authors, action: :read
18 | define :update_author, action: :update
19 | define :destroy_author, action: :destroy
20 | define :get_author, args: [:id], action: :by_id
21 | end
22 |
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/test_bed/lib/test_bed/blog/author.ex:
--------------------------------------------------------------------------------
1 | defmodule TestBed.Blog.Author do
2 | # Using Ash.Resource turns this module into an Ash resource.
3 | use Ash.Resource,
4 | # Tells Ash where the generated code interface belongs
5 | domain: TestBed.Blog,
6 | # Tells Ash you want this resource to store its data in Postgres.
7 | data_layer: AshPostgres.DataLayer
8 |
9 | # The Postgres keyword is specific to the AshPostgres module.
10 | postgres do
11 | # Tells Postgres what to call the table
12 | table("authors")
13 | # Tells Ash how to interface with the Postgres table
14 | repo(TestBed.Repo)
15 | end
16 |
17 | actions do
18 | # Exposes default built in actions to manage the resource
19 | defaults([:read, :destroy])
20 |
21 | create :create do
22 | primary? true
23 | # accept title as input
24 | accept([:name])
25 | end
26 |
27 | update :update do
28 | # accept content as input
29 | accept([:name])
30 | end
31 |
32 | # Defines custom read action which fetches post by id.
33 | read :by_id do
34 | # This action has one argument :id of type :uuid
35 | argument(:id, :uuid, allow_nil?: false)
36 | # Tells us we expect this action to return a single result
37 | get?(true)
38 | # Filters the `:id` given in the argument
39 | # against the `id` of each element in the resource
40 | filter(expr(id == ^arg(:id)))
41 | end
42 | end
43 |
44 | # Attributes are simple pieces of data that exist in your resource
45 | attributes do
46 | # Add an autogenerated UUID primary key called `:id`.
47 | uuid_primary_key(:id)
48 | # Add a string type attribute called `:title`
49 | attribute :name, :string do
50 | # We don't want the title to ever be `nil`
51 | allow_nil?(false)
52 | end
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/test_bed/lib/test_bed/blog/post.ex:
--------------------------------------------------------------------------------
1 | defmodule TestBed.Blog.Post do
2 |
3 | alias TestBed.Blog.Author
4 | # Using Ash.Resource turns this module into an Ash resource.
5 | use Ash.Resource,
6 | # Tells Ash where the generated code interface belongs
7 | domain: TestBed.Blog,
8 | # Tells Ash you want this resource to store its data in Postgres.
9 | data_layer: AshPostgres.DataLayer
10 |
11 | # The Postgres keyword is specific to the AshPostgres module.
12 | postgres do
13 | # Tells Postgres what to call the table
14 | table("posts")
15 | # Tells Ash how to interface with the Postgres table
16 | repo(TestBed.Repo)
17 | end
18 |
19 | actions do
20 | # Exposes default built in actions to manage the resource
21 | defaults([:read, :destroy])
22 |
23 | create :create do
24 |
25 | # accept title as input
26 | accept([:title, :author_id])
27 |
28 | argument :author, :map
29 |
30 | change manage_relationship(:author, type: :create)
31 | end
32 |
33 | update :update do
34 | # accept content as input
35 | accept([:content])
36 | end
37 |
38 | # Defines custom read action which fetches post by id.
39 | read :by_id do
40 | # This action has one argument :id of type :uuid
41 | argument(:id, :uuid, allow_nil?: false)
42 | # Tells us we expect this action to return a single result
43 | get?(true)
44 | # Filters the `:id` given in the argument
45 | # against the `id` of each element in the resource
46 | filter(expr(id == ^arg(:id)))
47 | end
48 | end
49 |
50 | # Attributes are simple pieces of data that exist in your resource
51 | attributes do
52 | # Add an autogenerated UUID primary key called `:id`.
53 | uuid_primary_key(:id)
54 | # Add a string type attribute called `:title`
55 | attribute :title, :string do
56 | # We don't want the title to ever be `nil`
57 | allow_nil?(false)
58 | end
59 |
60 | # Add a string type attribute called `:content`
61 | # If allow_nil? is not specified, then content can be nil
62 | attribute(:content, :string)
63 | end
64 |
65 | preparations do
66 | prepare build(load: [:author])
67 | end
68 |
69 | relationships do
70 | belongs_to :author, Author
71 | end
72 | end
73 |
--------------------------------------------------------------------------------
/test_bed/lib/test_bed/customers.ex:
--------------------------------------------------------------------------------
1 | defmodule TestBed.Customers do
2 | use Ash.Domain
3 |
4 | resources do
5 | resource TestBed.Customers.Customer do
6 | # Define an interface for calling resource actions.
7 | define :create_customer, action: :create
8 | define :list_customers, action: :read
9 | define :update_customer, action: :update
10 | define :destroy_customer, action: :destroy
11 | define :get_customer, args: [:id], action: :by_id
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/test_bed/lib/test_bed/customers/customer.ex:
--------------------------------------------------------------------------------
1 | defmodule TestBed.Customers.Customer do
2 | # Using Ash.Resource turns this module into an Ash resource.
3 | use Ash.Resource,
4 | # Tells Ash where the generated code interface belongs
5 | domain: TestBed.Customers,
6 | # Tells Ash you want this resource to store its data in Postgres.
7 | data_layer: AshPostgres.DataLayer
8 |
9 | # The Postgres keyword is specific to the AshPostgres module.
10 | postgres do
11 | # Tells Postgres what to call the table
12 | table("customers")
13 | # Tells Ash how to interface with the Postgres table
14 | repo(TestBed.Repo)
15 | end
16 |
17 | multitenancy do
18 | strategy :attribute
19 | attribute :tenant
20 | global? true
21 | end
22 |
23 | actions do
24 | # Exposes default built in actions to manage the resource
25 | defaults([:read, :destroy])
26 |
27 | create :create do
28 | # accept title as input
29 | accept([:name, :tenant])
30 | end
31 |
32 | update :update do
33 | # accept content as input
34 | accept([:name])
35 | end
36 |
37 | # Defines custom read action which fetches post by id.
38 | read :by_id do
39 | # This action has one argument :id of type :uuid
40 | argument(:id, :uuid, allow_nil?: false)
41 | # Tells us we expect this action to return a single result
42 | get?(true)
43 | # Filters the `:id` given in the argument
44 | # against the `id` of each element in the resource
45 | filter(expr(id == ^arg(:id)))
46 | end
47 | end
48 |
49 | # Attributes are simple pieces of data that exist in your resource
50 | attributes do
51 | # Add an autogenerated UUID primary key called `:id`.
52 | uuid_primary_key(:id)
53 | # Add a string type attribute called `:title`
54 | attribute :name, :string do
55 | # We don't want the title to ever be `nil`
56 | allow_nil?(false)
57 | end
58 |
59 | attribute :tenant, :string
60 | end
61 | end
62 |
--------------------------------------------------------------------------------
/test_bed/lib/test_bed/factory.ex:
--------------------------------------------------------------------------------
1 | defmodule TestBed.Factory do
2 | use Smokestack
3 |
4 | alias TestBed.Blog.Post
5 |
6 | factory Post do
7 | attribute :title, &Faker.Lorem.sentence/1
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/test_bed/lib/test_bed/mailer.ex:
--------------------------------------------------------------------------------
1 | defmodule TestBed.Mailer do
2 | use Swoosh.Mailer, otp_app: :test_bed
3 | end
4 |
--------------------------------------------------------------------------------
/test_bed/lib/test_bed/repo.ex:
--------------------------------------------------------------------------------
1 | defmodule TestBed.Repo do
2 | use AshPostgres.Repo, otp_app: :test_bed
3 |
4 | # Installs extensions that ash commonly uses
5 | def installed_extensions do
6 | ["ash-functions", "uuid-ossp", "citext"]
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/test_bed/lib/test_bed_web.ex:
--------------------------------------------------------------------------------
1 | defmodule TestBedWeb do
2 | @moduledoc """
3 | The entrypoint for defining your web interface, such
4 | as controllers, components, channels, and so on.
5 |
6 | This can be used in your application as:
7 |
8 | use TestBedWeb, :controller
9 | use TestBedWeb, :html
10 |
11 | The definitions below will be executed for every controller,
12 | component, etc, so keep them short and clean, focused
13 | on imports, uses and aliases.
14 |
15 | Do NOT define functions inside the quoted expressions
16 | below. Instead, define additional modules and import
17 | those modules here.
18 | """
19 |
20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
21 |
22 | def router do
23 | quote do
24 | use Phoenix.Router, helpers: false
25 |
26 | # Import common connection and controller functions to use in pipelines
27 | import Plug.Conn
28 | import Phoenix.Controller
29 | import Phoenix.LiveView.Router
30 | end
31 | end
32 |
33 | def channel do
34 | quote do
35 | use Phoenix.Channel
36 | end
37 | end
38 |
39 | def controller do
40 | quote do
41 | use Phoenix.Controller,
42 | formats: [:html, :json],
43 | layouts: [html: TestBedWeb.Layouts]
44 |
45 | import Plug.Conn
46 | import TestBedWeb.Gettext
47 |
48 | unquote(verified_routes())
49 | end
50 | end
51 |
52 | def live_view do
53 | quote do
54 | use Phoenix.LiveView,
55 | layout: {TestBedWeb.Layouts, :app}
56 |
57 | unquote(html_helpers())
58 | end
59 | end
60 |
61 | def live_component do
62 | quote do
63 | use Phoenix.LiveComponent
64 |
65 | unquote(html_helpers())
66 | end
67 | end
68 |
69 | def html do
70 | quote do
71 | use Phoenix.Component
72 |
73 | # Import convenience functions from controllers
74 | import Phoenix.Controller,
75 | only: [get_csrf_token: 0, view_module: 1, view_template: 1]
76 |
77 | # Include general helpers for rendering HTML
78 | unquote(html_helpers())
79 | end
80 | end
81 |
82 | defp html_helpers do
83 | quote do
84 | # HTML escaping functionality
85 | import Phoenix.HTML
86 | # Core UI components and translation
87 | import TestBedWeb.CoreComponents
88 | import TestBedWeb.Gettext
89 |
90 | # Shortcut for generating JS commands
91 | alias Phoenix.LiveView.JS
92 |
93 | # Routes generation with the ~p sigil
94 | unquote(verified_routes())
95 | end
96 | end
97 |
98 | def verified_routes do
99 | quote do
100 | use Phoenix.VerifiedRoutes,
101 | endpoint: TestBedWeb.Endpoint,
102 | router: TestBedWeb.Router,
103 | statics: TestBedWeb.static_paths()
104 | end
105 | end
106 |
107 | @doc """
108 | When used, dispatch to the appropriate controller/live_view/etc.
109 | """
110 | defmacro __using__(which) when is_atom(which) do
111 | apply(__MODULE__, which, [])
112 | end
113 | end
114 |
--------------------------------------------------------------------------------
/test_bed/lib/test_bed_web/components/core_components.ex:
--------------------------------------------------------------------------------
1 | defmodule TestBedWeb.CoreComponents do
2 | @moduledoc """
3 | Provides core UI components.
4 |
5 | At first glance, this module may seem daunting, but its goal is to provide
6 | core building blocks for your application, such as modals, tables, and
7 | forms. The components consist mostly of markup and are well-documented
8 | with doc strings and declarative assigns. You may customize and style
9 | them in any way you want, based on your application growth and needs.
10 |
11 | The default components use Tailwind CSS, a utility-first CSS framework.
12 | See the [Tailwind CSS documentation](https://tailwindcss.com) to learn
13 | how to customize them or feel free to swap in another framework altogether.
14 |
15 | Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
16 | """
17 | use Phoenix.Component
18 |
19 | alias Phoenix.LiveView.JS
20 | import TestBedWeb.Gettext
21 |
22 | @doc """
23 | Renders a modal.
24 |
25 | ## Examples
26 |
27 | <.modal id="confirm-modal">
28 | This is a modal.
29 |
30 |
31 | JS commands may be passed to the `:on_cancel` to configure
32 | the closing/cancel event, for example:
33 |
34 | <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}>
35 | This is another modal.
36 |
37 |
38 | """
39 | attr :id, :string, required: true
40 | attr :show, :boolean, default: false
41 | attr :on_cancel, JS, default: %JS{}
42 | slot :inner_block, required: true
43 |
44 | def modal(assigns) do
45 | ~H"""
46 |
53 |
54 |
62 |
63 |
64 | <.focus_wrap
65 | id={"#{@id}-container"}
66 | phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
67 | phx-key="escape"
68 | phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
69 | class="shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-white p-14 shadow-lg ring-1 transition"
70 | >
71 |
72 |
80 |
81 |
82 | <%= render_slot(@inner_block) %>
83 |
84 |
85 |
86 |
87 |
88 |
89 | """
90 | end
91 |
92 | @doc """
93 | Renders flash notices.
94 |
95 | ## Examples
96 |
97 | <.flash kind={:info} flash={@flash} />
98 | <.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!
99 | """
100 | attr :id, :string, doc: "the optional id of flash container"
101 | attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
102 | attr :title, :string, default: nil
103 | attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
104 | attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
105 |
106 | slot :inner_block, doc: "the optional inner block that renders the flash message"
107 |
108 | def flash(assigns) do
109 | assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
110 |
111 | ~H"""
112 |
hide("##{@id}")}
116 | role="alert"
117 | class={[
118 | "fixed top-2 right-2 mr-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1",
119 | @kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
120 | @kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
121 | ]}
122 | {@rest}
123 | >
124 |
125 | <.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" />
126 | <.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" />
127 | <%= @title %>
128 |
129 |
<%= msg %>
130 |
133 |
134 | """
135 | end
136 |
137 | @doc """
138 | Shows the flash group with standard titles and content.
139 |
140 | ## Examples
141 |
142 | <.flash_group flash={@flash} />
143 | """
144 | attr :flash, :map, required: true, doc: "the map of flash messages"
145 | attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
146 |
147 | def flash_group(assigns) do
148 | ~H"""
149 |
150 | <.flash kind={:info} title={gettext("Success!")} flash={@flash} />
151 | <.flash kind={:error} title={gettext("Error!")} flash={@flash} />
152 | <.flash
153 | id="client-error"
154 | kind={:error}
155 | title={gettext("We can't find the internet")}
156 | phx-disconnected={show(".phx-client-error #client-error")}
157 | phx-connected={hide("#client-error")}
158 | hidden
159 | >
160 | <%= gettext("Attempting to reconnect") %>
161 | <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
162 |
163 |
164 | <.flash
165 | id="server-error"
166 | kind={:error}
167 | title={gettext("Something went wrong!")}
168 | phx-disconnected={show(".phx-server-error #server-error")}
169 | phx-connected={hide("#server-error")}
170 | hidden
171 | >
172 | <%= gettext("Hang in there while we get back on track") %>
173 | <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
174 |
175 |
176 | """
177 | end
178 |
179 | @doc """
180 | Renders a simple form.
181 |
182 | ## Examples
183 |
184 | <.simple_form for={@form} phx-change="validate" phx-submit="save">
185 | <.input field={@form[:email]} label="Email"/>
186 | <.input field={@form[:username]} label="Username" />
187 | <:actions>
188 | <.button>Save
189 |
190 |
191 | """
192 | attr :for, :any, required: true, doc: "the datastructure for the form"
193 | attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
194 |
195 | attr :rest, :global,
196 | include: ~w(autocomplete name rel action enctype method novalidate target multipart),
197 | doc: "the arbitrary HTML attributes to apply to the form tag"
198 |
199 | slot :inner_block, required: true
200 | slot :actions, doc: "the slot for form actions, such as a submit button"
201 |
202 | def simple_form(assigns) do
203 | ~H"""
204 | <.form :let={f} for={@for} as={@as} {@rest}>
205 |
206 | <%= render_slot(@inner_block, f) %>
207 |
208 | <%= render_slot(action, f) %>
209 |
210 |
211 |
212 | """
213 | end
214 |
215 | @doc """
216 | Renders a button.
217 |
218 | ## Examples
219 |
220 | <.button>Send!
221 | <.button phx-click="go" class="ml-2">Send!
222 | """
223 | attr :type, :string, default: nil
224 | attr :class, :string, default: nil
225 | attr :rest, :global, include: ~w(disabled form name value)
226 |
227 | slot :inner_block, required: true
228 |
229 | def button(assigns) do
230 | ~H"""
231 |
242 | """
243 | end
244 |
245 | @doc """
246 | Renders an input with label and error messages.
247 |
248 | A `Phoenix.HTML.FormField` may be passed as argument,
249 | which is used to retrieve the input name, id, and values.
250 | Otherwise all attributes may be passed explicitly.
251 |
252 | ## Types
253 |
254 | This function accepts all HTML input types, considering that:
255 |
256 | * You may also set `type="select"` to render a `