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:
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