mitgliederverwaltung/lib/mv_web/live/member_live/index.ex
Moritz 5748fadf52
refactor: split long sort handler into smaller functions
Extract determine_new_sort/2, update_sort_components/4, and push_sort_url/3
from handle_info({:sort, ...}). Reduces function from 46 to 7 lines.
2025-11-10 17:03:50 +01:00

353 lines
9.5 KiB
Elixir

defmodule MvWeb.MemberLive.Index do
@moduledoc """
LiveView for displaying and managing the member list.
## Features
- Full-text search across member profiles using PostgreSQL tsvector
- Sortable columns (name, email, address fields)
- Bulk selection for future batch operations
- Real-time updates via LiveView
- Bookmarkable URLs with query parameters
## URL Parameters
- `query` - Search query string for full-text search
- `sort_field` - Field to sort by (e.g., :first_name, :email, :join_date)
- `sort_order` - Sort direction (:asc or :desc)
## Events
- `delete` - Remove a member from the database
- `select_member` - Toggle individual member selection
- `select_all` - Toggle selection of all visible members
## Implementation Notes
- Search uses PostgreSQL full-text search (plainto_tsquery)
- Sort state is synced with URL for bookmarkability
- Components communicate via `handle_info` for decoupling
"""
use MvWeb, :live_view
import Ash.Expr
import Ash.Query
@doc """
Initializes the LiveView state.
Sets up initial assigns for page title, search query, sort configuration,
and member selection. Actual data loading happens in `handle_params/3`.
"""
@impl true
def mount(_params, _session, socket) do
socket =
socket
|> assign(:page_title, gettext("Members"))
|> assign(:query, "")
|> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:selected_members, [])
# We call handle params to use the query from the URL
{:ok, socket}
end
# -----------------------------------------------------------------
# Handle Events
# -----------------------------------------------------------------
@doc """
Handles member-related UI events.
## Supported events:
- `"delete"` - Removes a member from the database
- `"select_member"` - Toggles individual member selection
- `"select_all"` - Toggles selection of all visible members
"""
@impl true
def handle_event("delete", %{"id" => id}, socket) do
member = Ash.get!(Mv.Membership.Member, id)
Ash.destroy!(member)
updated_members = Enum.reject(socket.assigns.members, &(&1.id == id))
{:noreply, assign(socket, :members, updated_members)}
end
@impl true
def handle_event("select_member", %{"id" => id}, socket) do
selected =
if id in socket.assigns.selected_members do
List.delete(socket.assigns.selected_members, id)
else
[id | socket.assigns.selected_members]
end
{:noreply, assign(socket, :selected_members, selected)}
end
@impl true
def handle_event("select_all", _params, socket) do
members = socket.assigns.members
all_ids = Enum.map(members, & &1.id)
selected =
if Enum.sort(socket.assigns.selected_members) == Enum.sort(all_ids) do
[]
else
all_ids
end
{:noreply, assign(socket, :selected_members, selected)}
end
# -----------------------------------------------------------------
# Handle Infos from Child Components
# -----------------------------------------------------------------
@doc """
Handles messages from child components.
## Supported messages:
- `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
- `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL
"""
@impl true
def handle_info({:sort, field_str}, socket) do
field = String.to_existing_atom(field_str)
{new_field, new_order} = determine_new_sort(field, socket)
socket
|> update_sort_components(socket.assigns.sort_field, new_field, new_order)
|> push_sort_url(new_field, new_order)
end
@impl true
def handle_info({:search_changed, q}, socket) do
socket = load_members(socket, q)
existing_field_query = socket.assigns.sort_field
existing_sort_query = socket.assigns.sort_order
# Build the URL with queries
query_params = %{
"query" => q,
"sort_field" => existing_field_query,
"sort_order" => existing_sort_query
}
# Set the new path with params
new_path = ~p"/members?#{query_params}"
# Push the new URL
{:noreply,
push_patch(socket,
to: new_path,
replace: true
)}
end
# -----------------------------------------------------------------
# Handle Params from the URL
# -----------------------------------------------------------------
@doc """
Handles URL parameter changes.
Parses query parameters for search query, sort field, and sort order,
then loads members accordingly. This enables bookmarkable URLs and
browser back/forward navigation.
"""
@impl true
def handle_params(params, _url, socket) do
socket =
socket
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> load_members(params["query"])
{:noreply, socket}
end
# -------------------------------------------------------------
# FUNCTIONS
# -------------------------------------------------------------
# Determines new sort field and order based on current state
defp determine_new_sort(field, socket) do
if socket.assigns.sort_field == field do
{field, toggle_order(socket.assigns.sort_order)}
else
{field, :asc}
end
end
# Updates both the active and old SortHeader components
defp update_sort_components(socket, old_field, new_field, new_order) do
active_id = :"sort_#{new_field}"
old_id = :"sort_#{old_field}"
# Update the new SortHeader
send_update(MvWeb.Components.SortHeaderComponent,
id: active_id,
sort_field: new_field,
sort_order: new_order
)
# Reset the current SortHeader
send_update(MvWeb.Components.SortHeaderComponent,
id: old_id,
sort_field: new_field,
sort_order: new_order
)
socket
end
# Builds sort URL and pushes navigation patch
defp push_sort_url(socket, field, order) do
query_params = %{
"query" => socket.assigns.query,
"sort_field" => Atom.to_string(field),
"sort_order" => Atom.to_string(order)
}
new_path = ~p"/members?#{query_params}"
{:noreply,
push_patch(socket,
to: new_path,
replace: true
)}
end
# Load members eg based on a query for sorting
defp load_members(socket, search_query) do
query =
Mv.Membership.Member
|> Ash.Query.new()
|> Ash.Query.select([
:id,
:first_name,
:last_name,
:email,
:street,
:house_number,
:postal_code,
:city,
:phone_number,
:join_date
])
# Apply the search filter first
query = apply_search_filter(query, search_query)
# Apply sorting based on current socket state
query = maybe_sort(query, socket.assigns.sort_field, socket.assigns.sort_order)
members = Ash.read!(query)
assign(socket, :members, members)
end
# -------------------------------------------------------------
# Helper Functions
# -------------------------------------------------------------
# Function to apply search query
defp apply_search_filter(query, search_query) do
if search_query && String.trim(search_query) != "" do
query
|> filter(expr(fragment("search_vector @@ plainto_tsquery('simple', ?)", ^search_query)))
else
query
end
end
# Functions to toggle sorting order
defp toggle_order(:asc), do: :desc
defp toggle_order(:desc), do: :asc
defp toggle_order(nil), do: :asc
# Function to sort the column if needed
defp maybe_sort(query, nil, _), do: query
defp maybe_sort(query, field, :asc) when not is_nil(field),
do: Ash.Query.sort(query, [{field, :asc}])
defp maybe_sort(query, field, :desc) when not is_nil(field),
do: Ash.Query.sort(query, [{field, :desc}])
defp maybe_sort(query, _, _), do: query
# Validate that a field is sortable
defp valid_sort_field?(field) when is_atom(field) do
valid_fields = [
:first_name,
:last_name,
:email,
:street,
:house_number,
:postal_code,
:city,
:phone_number,
:join_date
]
field in valid_fields
end
defp valid_sort_field?(_), do: false
# Function to maybe update the sort
defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do
field = determine_field(socket.assigns.sort_field, sf)
order = determine_order(socket.assigns.sort_order, so)
socket
|> assign(:sort_field, field)
|> assign(:sort_order, order)
end
defp maybe_update_sort(socket, _), do: socket
defp determine_field(default, sf) do
case sf do
"" ->
default
nil ->
default
sf when is_binary(sf) ->
sf
|> String.to_existing_atom()
|> handle_atom_conversion(default)
sf when is_atom(sf) ->
handle_atom_conversion(sf, default)
_ ->
default
end
end
defp handle_atom_conversion(val, default) when is_atom(val) do
if valid_sort_field?(val), do: val, else: default
end
defp handle_atom_conversion(_, default), do: default
defp determine_order(default, so) do
case so do
"" -> default
nil -> default
so when so in ["asc", "desc"] -> String.to_atom(so)
_ -> default
end
end
# Function to update search parameters
defp maybe_update_search(socket, %{"query" => query}) when query != "" do
assign(socket, :query, query)
end
defp maybe_update_search(socket, _params) do
# Keep the previous search query if no new one is provided
socket
end
end