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:
9
lib/action_requests_demo.ex
Normal file
9
lib/action_requests_demo.ex
Normal 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
|
||||
79
lib/action_requests_demo/action_requests.ex
Normal file
79
lib/action_requests_demo/action_requests.ex
Normal 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
|
||||
29
lib/action_requests_demo/action_requests/action_request.ex
Normal file
29
lib/action_requests_demo/action_requests/action_request.ex
Normal 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: 25
|
||||
}
|
||||
|
||||
@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
|
||||
34
lib/action_requests_demo/application.ex
Normal file
34
lib/action_requests_demo/application.ex
Normal 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
|
||||
5
lib/action_requests_demo/repo.ex
Normal file
5
lib/action_requests_demo/repo.ex
Normal file
@@ -0,0 +1,5 @@
|
||||
defmodule ActionRequestsDemo.Repo do
|
||||
use Ecto.Repo,
|
||||
otp_app: :action_requests_demo,
|
||||
adapter: Ecto.Adapters.SQLite3
|
||||
end
|
||||
103
lib/action_requests_demo_web.ex
Normal file
103
lib/action_requests_demo_web.ex
Normal 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
|
||||
76
lib/action_requests_demo_web/components/core_components.ex
Normal file
76
lib/action_requests_demo_web/components/core_components.ex
Normal 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">
|
||||
×
|
||||
</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
|
||||
92
lib/action_requests_demo_web/components/layouts.ex
Normal file
92
lib/action_requests_demo_web/components/layouts.ex
Normal 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
|
||||
@@ -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>
|
||||
50
lib/action_requests_demo_web/endpoint.ex
Normal file
50
lib/action_requests_demo_web/endpoint.ex
Normal 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
|
||||
301
lib/action_requests_demo_web/live/action_requests_live.ex
Normal file
301
lib/action_requests_demo_web/live/action_requests_live.ex
Normal 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
|
||||
18
lib/action_requests_demo_web/router.ex
Normal file
18
lib/action_requests_demo_web/router.ex
Normal 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
|
||||
Reference in New Issue
Block a user