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,292 @@
defmodule ActionRequestsDemoWeb.ActionRequestsLiveTest do
use ActionRequestsDemoWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias ActionRequestsDemo.Factory
setup do
# Seed 30 requests for pagination and filtering
for i <- 1..30 do
status = if rem(i, 2) == 0, do: "resolved", else: "unresolved"
assignment =
cond do
rem(i, 3) == 0 -> nil
rem(i, 3) == 1 -> 1
true -> 2
end
Factory.insert_action_request(%{
patient_name: "Patient #{i}",
status: status,
assigned_user_id: assignment
})
end
:ok
end
describe "ActionRequestsLive" do
test "renders the main view and shows the title", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
assert has_element?(view, "h1", "Action Requests")
end
test "shows filter form and table headers", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
assert has_element?(view, "form")
assert has_element?(view, "th", "Patient Name")
assert has_element?(view, "th", "Status")
assert has_element?(view, "th", "Assigned To")
assert has_element?(view, "th", "Created")
assert has_element?(view, "th", "Delivery Scheduled")
end
test "pagination links are present and work", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
assert has_element?(view, "nav[aria-label=Pagination]")
assert has_element?(view, "a", "Next")
assert has_element?(view, "a", "Previous")
# Click next page using the actual link element
render_click(element(view, "a", "Next"))
assert has_element?(view, "nav[aria-label=Pagination]")
end
test "status dropdown filter works", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{status: "resolved"})
assert has_element?(view, "td", "resolved")
refute has_element?(view, "td", "unresolved")
end
test "assignment dropdown filter works", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{assignment: "unassigned"})
assert has_element?(view, "td", "-")
end
test "fuzzy patient name search works", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{patient_name: "Patient 2"})
assert has_element?(view, "td", "Patient 2")
# Should match Patient 20, Patient 21, etc.
assert has_element?(view, "td", "Patient 20")
end
test "pagination displays correct records per page", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
# Default ordering is inserted_at: :desc, so page 1 shows Patient 30..6
for i <- 30..16//-1 do
assert has_element?(view, "td", "Patient #{i}")
end
# Page 2 should show Patient 5..1
render_click(element(view, "a", "Next"))
for i <- 15..1//-1 do
assert has_element?(view, "td", "Patient #{i}")
end
end
test "sorting by patient name ascending works", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
# Click on Patient Name header to sort ascending
view |> element("a", "Patient Name") |> render_click()
# Should show Patient 1, Patient 10, Patient 11, etc. (alphabetical)
assert has_element?(view, "td", "Patient 1")
assert has_element?(view, "span", "")
end
test "sorting by patient name descending works", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
# Click twice to get descending order
view |> element("a", "Patient Name") |> render_click()
view |> element("a", "Patient Name") |> render_click()
assert has_element?(view, "span", "")
end
test "sorting by status works", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
view |> element("a", "Status") |> render_click()
# After sorting by status, should have a sort indicator
assert has_element?(view, "span", "")
end
test "sorting by created date works", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
# Default is descending, should show down arrow
assert has_element?(view, "span", "")
# Click to reverse
view |> element("a", "Created") |> render_click()
assert has_element?(view, "span", "")
end
test "sorting by delivery scheduled works", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
view |> element("a", "Delivery Scheduled") |> render_click()
assert has_element?(view, "span", "")
end
test "combining status filter with sorting works", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?status=resolved")
view |> element("a", "Patient Name") |> render_click()
# Should only show resolved items, sorted by name
html = render(view)
assert html =~ "bg-green-100 text-green-800"
refute html =~ "bg-yellow-100 text-yellow-800"
assert has_element?(view, "span", "")
end
test "combining multiple filters works", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?status=resolved&assignment=assigned")
# Should show only resolved AND assigned items
html = render(view)
assert html =~ "bg-green-100 text-green-800"
refute html =~ "bg-yellow-100 text-yellow-800"
# Should show User # but not unassigned items in the Assigned To column
assert html =~ "User #"
end
test "mine filter shows only current user's assignments", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{assignment: "mine"})
# Current user is set to 1 in mount
html = render(view)
assert html =~ "User #1"
# Should not show User #2
refute html =~ "User #2"
end
test "assigned filter shows all assigned items regardless of user", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{assignment: "assigned"})
# Should show both User #1 and User #2
html = render(view)
assert html =~ "User #1"
assert html =~ "User #2"
end
test "pagination metadata displays correctly", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
# Page 1 of 2, showing 15 of 30 records
assert has_element?(view, "div", "Page 1 of 2")
assert has_element?(view, "div", "showing 15 of 30 records")
end
test "pagination metadata updates after navigating", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_click(element(view, "a", "Next"))
# Page 2 of 2, showing 15 of 30 records
assert has_element?(view, "div", "Page 2 of 2")
assert has_element?(view, "div", "showing 15 of 30 records")
end
test "pagination metadata updates with filters", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{status: "resolved"})
# Should show fewer total records
assert has_element?(view, "div", "of 15 records")
end
test "status badge shows green for resolved", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{status: "resolved"})
html = render(view)
assert html =~ "bg-green-100 text-green-800"
assert html =~ "resolved"
end
test "status badge shows yellow for unresolved", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{status: "unresolved"})
html = render(view)
assert html =~ "bg-yellow-100 text-yellow-800"
assert html =~ "unresolved"
end
test "unassigned items show dash", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{assignment: "unassigned"})
# Should show "-" for unassigned items
assert has_element?(view, "td", "-")
end
test "clicking page number navigates to that page", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
# Click on page 2
view |> element("a", "2") |> render_click()
assert has_element?(view, "div", "Page 2 of 2")
end
test "clicking page 1 navigates to first page", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
# Go to page 2 first
render_click(element(view, "a", "Next"))
# Click on page 1
view |> element("a", "1") |> render_click()
assert has_element?(view, "div", "Page 1 of 2")
end
test "previous link is disabled on first page", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
html = render(view)
assert html =~ "bg-gray-200 text-gray-500 cursor-not-allowed"
assert html =~ "Previous"
end
test "next link is disabled on last page", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
# Navigate to last page
render_click(element(view, "a", "Next"))
html = render(view)
# Next button should be disabled
assert html =~ "bg-gray-200 text-gray-500 cursor-not-allowed"
assert html =~ "Next"
end
test "filters persist through pagination", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{status: "resolved"})
render_click(element(view, "a", "Next"))
# Should still show only resolved items
assert has_element?(view, "td", "resolved")
refute has_element?(view, "td", "unresolved")
end
test "sorting persists through pagination", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
view |> element("a", "Patient Name") |> render_click()
render_click(element(view, "a", "Next"))
# Should still show sort indicator
assert has_element?(view, "span", "")
end
test "clearing patient name filter shows all results", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{patient_name: "Patient 2"})
# Should show filtered results
assert has_element?(view, "td", "Patient 2")
# Clear filter
render_change(view, "filter", %{patient_name: ""})
# Should show all patients again
assert has_element?(view, "td", "Patient 1")
assert has_element?(view, "td", "Patient 30")
end
test "clearing status filter shows all statuses", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_change(view, "filter", %{status: "resolved"})
refute has_element?(view, "td", "unresolved")
# Clear filter
render_change(view, "filter", %{status: ""})
# Should show both statuses
assert has_element?(view, "td", "resolved")
assert has_element?(view, "td", "unresolved")
end
test "delivery scheduled date displays when present", %{conn: conn} do
{:ok, _view, html} = live(conn, "/")
# Most records should have delivery scheduled dates in the format YYYY-MM-DD HH:MM
assert html =~ ~r/\d{4}-\d{2}-\d{2} \d{2}:\d{2}/
end
end
end

38
test/support/conn_case.ex Normal file
View File

@@ -0,0 +1,38 @@
defmodule ActionRequestsDemoWeb.ConnCase do
@moduledoc """
This module defines the test case to be used by
tests that require setting up a connection.
Such tests rely on `Phoenix.ConnTest` and also
import other functionality to make it easier
to build common data structures and query the data layer.
Finally, if the test case interacts with the database,
we enable the SQL sandbox, so changes done to the database
are reverted at the end of every test. If you are using
PostgreSQL, you can even run database tests asynchronously
by setting `use ActionRequestsDemoWeb.ConnCase, async: true`, although
this option is not recommended for other databases.
"""
use ExUnit.CaseTemplate
using do
quote do
# The default endpoint for testing
@endpoint ActionRequestsDemoWeb.Endpoint
use ActionRequestsDemoWeb, :verified_routes
# Import conveniences for testing with connections
import Plug.Conn
import Phoenix.ConnTest
import ActionRequestsDemoWeb.ConnCase
end
end
setup tags do
ActionRequestsDemo.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
end

58
test/support/data_case.ex Normal file
View File

@@ -0,0 +1,58 @@
defmodule ActionRequestsDemo.DataCase do
@moduledoc """
This module defines the setup for tests requiring
access to the application's data layer.
You may define functions here to be used as helpers in
your tests.
Finally, if the test case interacts with the database,
we enable the SQL sandbox, so changes done to the database
are reverted at the end of every test. If you are using
PostgreSQL, you can even run database tests asynchronously
by setting `use ActionRequestsDemo.DataCase, async: true`, although
this option is not recommended for other databases.
"""
use ExUnit.CaseTemplate
using do
quote do
alias ActionRequestsDemo.Repo
import Ecto
import Ecto.Changeset
import Ecto.Query
import ActionRequestsDemo.DataCase
end
end
setup tags do
ActionRequestsDemo.DataCase.setup_sandbox(tags)
:ok
end
@doc """
Sets up the sandbox based on the test tags.
"""
def setup_sandbox(tags) do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(ActionRequestsDemo.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
end
@doc """
A helper that transforms changeset errors into a map of messages.
assert {:error, changeset} = Accounts.create_user(%{password: "short"})
assert "password is too short" in errors_on(changeset).password
assert %{password: ["password is too short"]} = errors_on(changeset)
"""
def errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
end
end

17
test/support/factory.ex Normal file
View File

@@ -0,0 +1,17 @@
defmodule ActionRequestsDemo.Factory do
alias ActionRequestsDemo.Repo
alias ActionRequestsDemo.ActionRequests.ActionRequest
def insert_action_request(attrs \\ %{}) do
default_attrs = %{
patient_name: "Patient #{System.unique_integer([:positive])}",
status: Enum.random(["resolved", "unresolved"]),
assigned_user_id: Enum.random([nil, 1, 2]),
delivery_scheduled_at: ~N[2025-11-16 10:00:00]
}
%ActionRequest{}
|> ActionRequest.changeset(Map.merge(default_attrs, attrs))
|> Repo.insert!()
end
end

2
test/test_helper.exs Normal file
View File

@@ -0,0 +1,2 @@
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(ActionRequestsDemo.Repo, :manual)