Initial commit: Phoenix LiveView demo for interactive data tables with filtering, sorting, pagination, URL state, and progressive enhancement

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, 25-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:
2025-11-16 10:24:06 +00:00
commit de1b1bd484
38 changed files with 3718 additions and 0 deletions

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="300"
/>
</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