Initial commit: Phoenix LiveView demo for interactive data tables with filtering, sorting, pagination, URL state, and progressive enhancement
All checks were successful
build / build (push) Successful in 11s

Implements a fully-featured action requests table in a single LiveView module using Flop, Ecto, and SQLite. Includes:

- Fuzzy search, status/assignment filters, column sorting, 15-per-page pagination
- Real-time updates, bookmarkable URLs via `handle_params/3`
- JS-disabled fallback with GET forms (no duplicate logic)
- 1,000,000 seeded records, Tailwind + DaisyUI styling, light/dark themes
- Comprehensive README with comparisons to Django+React/Rails+React stacks
- 31 tests covering all scenarios

Tech: Phoenix 1.8+, LiveView, Flop, Ecto, SQLite, Elixir 1.15+
This commit is contained in:
James Greenwood
2025-11-17 14:42:00 +00:00
commit cc4cc65950
41 changed files with 3890 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
defmodule ActionRequestsDemo do
@moduledoc """
ActionRequestsDemo keeps the contexts that define your domain
and business logic.
Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others.
"""
end

View File

@@ -0,0 +1,79 @@
defmodule ActionRequestsDemo.ActionRequests do
import Ecto.Query
alias ActionRequestsDemo.{Repo, ActionRequests.ActionRequest}
def list_action_requests(params, current_user_id \\ nil) do
# Extract custom filters
patient_name = get_in(params, ["patient_name"]) |> clean_param()
status = get_in(params, ["status"]) |> clean_param()
assignment = get_in(params, ["assignment"]) |> clean_param()
# Build base query with custom filters
query =
ActionRequest
|> apply_patient_name_filter(patient_name)
|> apply_status_filter(status)
|> apply_assignment_filter(assignment, current_user_id)
# Use Flop for sorting and pagination only
flop_params = Map.drop(params, ["patient_name", "status", "assignment"])
# Set default sort if not specified
flop_params =
if is_nil(flop_params["order_by"]) || flop_params["order_by"] == "" do
flop_params
|> Map.put("order_by", ["inserted_at"])
|> Map.put("order_directions", ["desc"])
else
# Convert order_by and order_directions to arrays if they're strings
flop_params
|> maybe_listify("order_by")
|> maybe_listify("order_directions")
end
Flop.validate_and_run(query, flop_params, for: ActionRequest, repo: Repo)
end
defp maybe_listify(params, key) do
case params[key] do
nil -> params
"" -> Map.delete(params, key)
val when is_binary(val) -> Map.put(params, key, [val])
val when is_atom(val) -> Map.put(params, key, [val])
val when is_list(val) -> params
_ -> params
end
end
defp clean_param(nil), do: nil
defp clean_param(""), do: nil
defp clean_param("all"), do: nil
defp clean_param(val), do: val
defp apply_patient_name_filter(query, nil), do: query
defp apply_patient_name_filter(query, name) do
pattern = "%#{name}%"
where(query, [a], like(a.patient_name, ^pattern))
end
defp apply_status_filter(query, nil), do: query
defp apply_status_filter(query, status) do
where(query, [a], a.status == ^status)
end
defp apply_assignment_filter(query, nil, _), do: query
defp apply_assignment_filter(query, "mine", current_user_id) do
where(query, [a], a.assigned_user_id == ^current_user_id)
end
defp apply_assignment_filter(query, "assigned", _) do
where(query, [a], not is_nil(a.assigned_user_id))
end
defp apply_assignment_filter(query, "unassigned", _) do
where(query, [a], is_nil(a.assigned_user_id))
end
end

View File

@@ -0,0 +1,29 @@
defmodule ActionRequestsDemo.ActionRequests.ActionRequest do
use Ecto.Schema
import Ecto.Changeset
@derive {
Flop.Schema,
filterable: [:patient_name, :status, :assigned_user_id],
sortable: [:patient_name, :status, :inserted_at, :delivery_scheduled_at],
default_limit: 15
}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "action_requests" do
field :patient_name, :string
field :status, :string
field :assigned_user_id, :integer
field :delivery_scheduled_at, :naive_datetime
timestamps(type: :utc_datetime)
end
@doc false
def changeset(action_request, attrs) do
action_request
|> cast(attrs, [:patient_name, :status, :assigned_user_id, :delivery_scheduled_at])
|> validate_required([:patient_name, :status])
end
end

View File

@@ -0,0 +1,34 @@
defmodule ActionRequestsDemo.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [
ActionRequestsDemo.Repo,
{Ecto.Migrator,
repos: Application.fetch_env!(:action_requests_demo, :ecto_repos), skip: skip_migrations?()},
{Phoenix.PubSub, name: ActionRequestsDemo.PubSub},
ActionRequestsDemoWeb.Endpoint
]
opts = [strategy: :one_for_one, name: ActionRequestsDemo.Supervisor]
Supervisor.start_link(children, opts)
end
# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
@impl true
def config_change(changed, _new, removed) do
ActionRequestsDemoWeb.Endpoint.config_change(changed, removed)
:ok
end
defp skip_migrations?() do
# By default, sqlite migrations are run when using a release
System.get_env("RELEASE_NAME") == nil
end
end

View File

@@ -0,0 +1,70 @@
defmodule ActionRequestsDemo.Release do
@moduledoc """
Helpers for running tasks in a release environment.
This module is intended for commands invoked via:
bin/action_requests_demo eval "ActionRequestsDemo.Release.seed()"
"""
@app :action_requests_demo
@doc """
Run the database seeds script inside a release.
This is designed to be run against a *running* release (e.g. via
`bin/action_requests_demo eval ...` inside your Docker container),
so it does **not** try to start the application or endpoint again.
It only ensures Faker is running, then evaluates the standard
`priv/repo/seeds.exs` file, which uses `ActionRequestsDemo.Repo`.
"""
def seed do
ensure_app_started(ActionRequestsDemo.Repo)
ensure_app_started(:faker)
seeds_path = Application.app_dir(@app, "priv/repo/seeds.exs")
Code.require_file(seeds_path)
end
defp ensure_app_started(app_or_repo) do
case Application.ensure_all_started(app_or_repo) do
{:ok, _} ->
:ok
{:error, {:already_started, _pid}} ->
:ok
{:error, {:not_started, _}} ->
start_repo(app_or_repo)
{:error, reason} ->
raise "Could not start #{inspect(app_or_repo)}: #{inspect(reason)}"
end
rescue
UndefinedFunctionError ->
start_repo(app_or_repo)
end
defp start_repo(repo) when is_atom(repo) do
case repo.start_link() do
{:ok, _pid} ->
:ok
{:error, {:already_started, _pid}} ->
:ok
other ->
raise "Could not start #{inspect(repo)}: #{inspect(other)}"
end
end
defp start_repo(app) do
case Application.ensure_all_started(app) do
{:ok, _} -> :ok
{:error, {:already_started, _}} -> :ok
other -> raise "Could not start #{inspect(app)}: #{inspect(other)}"
end
end
end

View File

@@ -0,0 +1,5 @@
defmodule ActionRequestsDemo.Repo do
use Ecto.Repo,
otp_app: :action_requests_demo,
adapter: Ecto.Adapters.SQLite3
end

View File

@@ -0,0 +1,103 @@
defmodule ActionRequestsDemoWeb do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, components, channels, and so on.
This can be used in your application as:
use ActionRequestsDemoWeb, :controller
use ActionRequestsDemoWeb, :html
The definitions below will be executed for every controller,
component, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define additional modules and import
those modules here.
"""
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
def router do
quote do
use Phoenix.Router, helpers: false
# Import common connection and controller functions to use in pipelines
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def controller do
quote do
use Phoenix.Controller, formats: [:html]
import Plug.Conn
unquote(verified_routes())
end
end
def live_view do
quote do
use Phoenix.LiveView
unquote(html_helpers())
end
end
def live_component do
quote do
use Phoenix.LiveComponent
unquote(html_helpers())
end
end
def html do
quote do
use Phoenix.Component
# Import convenience functions from controllers
import Phoenix.Controller,
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
# Include general helpers for rendering HTML
unquote(html_helpers())
end
end
defp html_helpers do
quote do
# HTML escaping functionality
import Phoenix.HTML
# Core UI components
import ActionRequestsDemoWeb.CoreComponents
# Common modules used in templates
alias Phoenix.LiveView.JS
alias ActionRequestsDemoWeb.Layouts
# Routes generation with the ~p sigil
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: ActionRequestsDemoWeb.Endpoint,
router: ActionRequestsDemoWeb.Router,
statics: ActionRequestsDemoWeb.static_paths()
end
end
@doc """
When used, dispatch to the appropriate controller/live_view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end

View File

@@ -0,0 +1,76 @@
defmodule ActionRequestsDemoWeb.CoreComponents do
@moduledoc """
Provides core UI components. Only the minimal set for demo: icon, show/hide JS helpers, and error translation.
"""
use Phoenix.Component
alias Phoenix.LiveView.JS
@doc """
Renders flash notices.
"""
attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
def flash(assigns) do
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
~H"""
<div
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class="toast toast-top toast-end z-50"
{@rest}
>
<div class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info",
@kind == :error && "alert-error"
]}>
<div>
<p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p>
</div>
<div class="flex-1" />
<button type="button" class="group self-start cursor-pointer" aria-label="close">
&times;
</button>
</div>
</div>
"""
end
## JS Commands
@doc """
Shows an element with animation.
"""
def show(js \\ %JS{}, selector) do
JS.show(js,
to: selector,
time: 300,
transition:
{"transition-all ease-out duration-300",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
)
end
@doc """
Hides an element with animation.
"""
def hide(js \\ %JS{}, selector) do
JS.hide(js,
to: selector,
time: 200,
transition:
{"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
)
end
end

View File

@@ -0,0 +1,92 @@
defmodule ActionRequestsDemoWeb.Layouts do
@moduledoc """
This module holds layouts and related functionality
used by your application.
"""
use ActionRequestsDemoWeb, :html
# Embed all files in layouts/* within this module.
# The default root.html.heex file contains the HTML
# skeleton of your application, namely HTML headers
# and other static content.
embed_templates "layouts/*"
@doc """
Renders your app layout.
This function is typically invoked from every template,
and it often contains your application menu, sidebar,
or similar.
## Examples
<Layouts.app flash={@flash}>
<h1>Content</h1>
</Layouts.app>
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map,
default: nil,
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
slot :inner_block, required: true
def app(assigns) do
~H"""
<main>
{render_slot(@inner_block)}
</main>
<.flash_group flash={@flash} />
"""
end
@doc """
Shows the flash group with standard titles and content.
## Examples
<.flash_group flash={@flash} />
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
<.flash
id="client-error"
kind={:error}
title="We can't find the internet"
phx-disconnected={show(".phx-client-error #client-error") |> JS.remove_attribute("hidden")}
phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
Attempting to reconnect…
</.flash>
<.flash
id="server-error"
kind={:error}
title="Something went wrong!"
phx-disconnected={show(".phx-server-error #server-error") |> JS.remove_attribute("hidden")}
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
Attempting to reconnect…
</.flash>
</div>
"""
end
@doc """
Provides dark vs light theme toggle based on themes defined in app.css.
See <head> in root.html.heex which applies the theme before page load.
"""
end

View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<.live_title default="ActionRequestsDemo" suffix=" · Phoenix Framework">
{assigns[:page_title]}
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
</script>
<script>
(() => {
const setTheme = (theme) => {
if (theme === "system") {
localStorage.removeItem("phx:theme");
document.documentElement.removeAttribute("data-theme");
} else {
localStorage.setItem("phx:theme", theme);
document.documentElement.setAttribute("data-theme", theme);
}
};
if (!document.documentElement.hasAttribute("data-theme")) {
setTheme(localStorage.getItem("phx:theme") || "system");
}
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
})();
</script>
</head>
<body>
{@inner_content}
</body>
</html>

View File

@@ -0,0 +1,50 @@
defmodule ActionRequestsDemoWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :action_requests_demo
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
@session_options [
store: :cookie,
key: "_action_requests_demo_key",
signing_salt: "gcCGnfEA",
same_site: "Lax"
]
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]],
longpoll: [connect_info: [session: @session_options]]
# Serve at "/" the static files from "priv/static" directory.
#
# When code reloading is disabled (e.g., in production),
# the `gzip` option is enabled to serve compressed
# static files generated by running `phx.digest`.
plug Plug.Static,
at: "/",
from: :action_requests_demo,
gzip: not code_reloading?,
only: ActionRequestsDemoWeb.static_paths()
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
plug Phoenix.LiveReloader
plug Phoenix.CodeReloader
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :action_requests_demo
end
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug ActionRequestsDemoWeb.Router
end

View File

@@ -0,0 +1,301 @@
defmodule ActionRequestsDemoWeb.ActionRequestsLive do
use ActionRequestsDemoWeb, :live_view
alias ActionRequestsDemo.ActionRequests
def mount(_params, _session, socket) do
{:ok, assign(socket, current_user_id: 1)}
end
def handle_params(params, _uri, socket) do
case ActionRequests.list_action_requests(params, socket.assigns.current_user_id) do
{:ok, {action_requests, meta}} ->
{:noreply,
socket
|> assign(:action_requests, action_requests)
|> assign(:meta, meta)
|> assign(:params, params)}
{:error, _meta} ->
{:noreply,
socket
|> assign(:action_requests, [])
|> assign(:meta, %Flop.Meta{})
|> assign(:params, params)}
end
end
def handle_event("filter", params, socket) do
# Navigate with new params, which will trigger handle_params
{:noreply, push_patch(socket, to: ~p"/?#{params}")}
end
def handle_event("patch", params, socket) do
# Handle pagination events from paginator links
{:noreply, push_patch(socket, to: ~p"/?#{params}")}
end
def render(assigns) do
~H"""
<div class="px-4 py-8 sm:px-6 lg:px-8">
<div class="mx-auto max-w-7xl">
<h1 class="text-3xl font-bold mb-8">Action Requests</h1>
<.form
for={%{}}
method="get"
action={~p"/"}
phx-submit="filter"
phx-change="filter"
class="mb-6"
>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div>
<label class="block text-sm font-medium text-white mb-1">Status</label>
<select
name="status"
class="w-full rounded-md border border-gray-400 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2"
>
<option value="" selected={@params["status"] in [nil, "", "all"]}>
All
</option>
<option value="resolved" selected={@params["status"] == "resolved"}>
Resolved
</option>
<option value="unresolved" selected={@params["status"] == "unresolved"}>
Unresolved
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-white mb-1">Assignment</label>
<select
name="assignment"
class="w-full rounded-md border border-gray-400 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2"
>
<option value="" selected={@params["assignment"] in [nil, ""]}>
All
</option>
<option value="mine" selected={@params["assignment"] == "mine"}>
Mine
</option>
<option value="assigned" selected={@params["assignment"] == "assigned"}>
Assigned
</option>
<option value="unassigned" selected={@params["assignment"] == "unassigned"}>
Unassigned
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-white mb-1">Patient Name</label>
<input
type="text"
name="patient_name"
value={@params["patient_name"] || ""}
placeholder="Search by name..."
autocomplete="off"
class="w-full rounded-md border border-gray-400 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 leading-4"
phx-debounce="100"
/>
</div>
<div class="flex items-end">
<noscript>
<button
type="submit"
class="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500"
>
Apply Filters
</button>
</noscript>
</div>
</div>
</.form>
<div class="overflow-x-auto rounded-lg border border-gray-200">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
<.sort_link meta={@meta} params={@params} field={:patient_name}>Patient Name</.sort_link>
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
<.sort_link meta={@meta} params={@params} field={:status}>Status</.sort_link>
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Assigned To
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
<.sort_link meta={@meta} params={@params} field={:inserted_at}>Created</.sort_link>
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
<.sort_link meta={@meta} params={@params} field={:delivery_scheduled_at}>
Delivery Scheduled
</.sort_link>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<%= for request <- @action_requests do %>
<tr>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
{request.patient_name}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm">
<.status_badge status={request.status} />
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{if request.assigned_user_id, do: "User ##{request.assigned_user_id}", else: "-"}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{Calendar.strftime(request.inserted_at, "%Y-%m-%d %H:%M")}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
<%= if request.delivery_scheduled_at do %>
{Calendar.strftime(request.delivery_scheduled_at, "%Y-%m-%d %H:%M")}
<% else %>
-
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<div class="mt-4 flex items-center justify-between">
<div class="text-sm text-white">
Page {@meta.current_page} of {@meta.total_pages} (showing {length(@action_requests)} of {@meta.total_count} records)
</div>
<nav aria-label="Pagination" class="flex flex-wrap gap-1 items-center justify-center">
<.link
patch={~p"/?#{Map.merge(@params, %{"page" => 1})}"}
class={[
"rounded-md px-2 py-1 text-sm font-semibold",
(@meta.current_page == 1 && "bg-blue-600 text-white") ||
"bg-white text-gray-900 hover:bg-gray-50 shadow-sm ring-1 ring-inset ring-gray-300"
]}
aria-current={@meta.current_page == 1 && "page"}
>
1
</.link>
<%= if @meta.current_page > 4 do %>
<span class="px-2 py-1 text-gray-500">...</span>
<% end %>
<%= for page <- page_window(@meta.current_page, @meta.total_pages, 2) do %>
<%= if page != 1 and page != @meta.total_pages do %>
<.link
patch={~p"/?#{Map.merge(@params, %{"page" => page})}"}
class={[
"rounded-md px-2 py-1 text-sm font-semibold",
(page == @meta.current_page && "bg-blue-600 text-white") ||
"bg-white text-gray-900 hover:bg-blue-50 shadow-sm ring-1 ring-inset ring-gray-300"
]}
aria-current={page == @meta.current_page && "page"}
>
{page}
</.link>
<% end %>
<% end %>
<%= if @meta.current_page < @meta.total_pages - 3 do %>
<span class="px-2 py-1 text-gray-500">...</span>
<% end %>
<%= if @meta.total_pages > 1 do %>
<.link
patch={~p"/?#{Map.merge(@params, %{"page" => @meta.total_pages})}"}
class={[
"rounded-md px-2 py-1 text-sm font-semibold",
(@meta.current_page == @meta.total_pages && "bg-blue-600 text-white") ||
"bg-white text-gray-900 hover:bg-gray-50 shadow-sm ring-1 ring-inset ring-gray-300"
]}
aria-current={@meta.current_page == @meta.total_pages && "page"}
>
{@meta.total_pages}
</.link>
<% end %>
<.link
patch={~p"/?#{Map.merge(@params, %{"page" => @meta.previous_page})}"}
class={[
"rounded-md px-2 py-1 text-sm font-semibold",
(!@meta.has_previous_page? && "bg-gray-200 text-gray-500 cursor-not-allowed") ||
"bg-white text-gray-900 hover:bg-gray-50 shadow-sm ring-1 ring-inset ring-gray-300"
]}
aria-disabled={!@meta.has_previous_page?}
>
Previous
</.link>
<.link
patch={~p"/?#{Map.merge(@params, %{"page" => @meta.next_page})}"}
class={[
"rounded-md px-2 py-1 text-sm font-semibold",
(!@meta.has_next_page? && "bg-gray-200 text-gray-500 cursor-not-allowed") ||
"bg-white text-gray-900 hover:bg-gray-50 shadow-sm ring-1 ring-inset ring-gray-300"
]}
aria-disabled={!@meta.has_next_page?}
>
Next
</.link>
</nav>
</div>
</div>
</div>
"""
end
defp page_window(current, total, radius) do
# Returns a list of page numbers centered around current page, excluding first/last
start_page = max(current - radius, 2)
end_page = min(current + radius, total - 1)
if end_page >= start_page do
Enum.to_list(start_page..end_page)
else
[]
end
end
defp status_badge(assigns) do
~H"""
<span class={[
"inline-flex rounded-full px-2 py-1 text-xs font-semibold leading-5",
if(@status == "resolved",
do: "bg-green-100 text-green-800",
else: "bg-yellow-100 text-yellow-800"
)
]}>
{@status}
</span>
"""
end
defp sort_link(assigns) do
current_order =
if assigns.meta.flop.order_by == [assigns.field],
do: List.first(assigns.meta.flop.order_directions),
else: nil
next_direction = if current_order == :asc, do: :desc, else: :asc
assigns = assign(assigns, :next_direction, next_direction)
assigns = assign(assigns, :current_order, current_order)
~H"""
<.link
patch={
~p"/?#{Map.merge(@params, %{"order_by" => @field, "order_directions" => @next_direction})}"
}
class="group inline-flex"
>
{render_slot(@inner_block)}
<%= if @current_order == :asc do %>
<span class="ml-2">↑</span>
<% end %>
<%= if @current_order == :desc do %>
<span class="ml-2">↓</span>
<% end %>
</.link>
"""
end
end

View File

@@ -0,0 +1,18 @@
defmodule ActionRequestsDemoWeb.Router do
use ActionRequestsDemoWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {ActionRequestsDemoWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
scope "/", ActionRequestsDemoWeb do
pipe_through :browser
live "/", ActionRequestsLive
end
end