mitgliederverwaltung/lib/mv_web/live/member_live/index.ex
Rafael Epplée f25f0216ec
All checks were successful
continuous-integration/drone/push Build is passing
Refactor column visibility logic
2025-12-02 12:54:07 +01:00

764 lines
25 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
require Ash.Query
import Ash.Expr
alias Mv.Membership
alias MvWeb.MemberLive.Index.Formatter
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
@custom_field_prefix "custom_field_"
# Member fields that are loaded for the overview
# Uses constants from Mv.Constants to ensure consistency
# Note: :id is always included for member identification
# All member fields are loaded, but visibility is controlled via settings
@overview_fields [:id | Mv.Constants.member_fields()]
@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
# Load custom fields that should be shown in overview
# Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView
# and result in a 500 error page. This is appropriate for LiveViews where errors
# should be visible to the user rather than silently failing.
custom_fields_visible =
Mv.Membership.CustomField
|> Ash.Query.filter(expr(show_in_overview == true))
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
# Load settings once to avoid N+1 queries
settings =
case Membership.get_settings() do
{:ok, s} -> s
# Fallback if settings can't be loaded
{:error, _} -> %{member_field_visibility: %{}}
end
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, [])
|> assign(:custom_fields_visible, custom_fields_visible)
|> assign(:member_fields_visible, get_visible_member_fields(settings))
# 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
# Note: Using bang versions (!) - errors will be handled by Phoenix LiveView
# This ensures users see error messages if deletion fails (e.g., permission denied)
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
# Handle both atom and string field names (for custom fields)
field =
try do
String.to_existing_atom(field_str)
rescue
ArgumentError -> field_str
end
{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"])
|> prepare_dynamic_cols()
{:noreply, socket}
end
# Prepares dynamic column definitions for custom fields that should be shown in the overview.
#
# Creates a list of column definitions, each containing:
# - `:custom_field` - The CustomField resource
# - `:render` - A function that formats the custom field value for a given member
#
# Returns the socket with `:dynamic_cols` assigned.
defp prepare_dynamic_cols(socket) do
dynamic_cols =
Enum.map(socket.assigns.custom_fields_visible, fn custom_field ->
%{
custom_field: custom_field,
render: fn member ->
case get_custom_field_value(member, custom_field) do
nil ->
""
cfv ->
Formatter.format_custom_field_value(cfv.value, custom_field)
end
end
}
end)
assign(socket, :dynamic_cols, dynamic_cols)
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 = to_sort_id(new_field)
old_id = to_sort_id(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
# Converts a field (atom or string) to a sort component ID atom
# Handles both existing atoms and strings that need to be converted
defp to_sort_id(field) when is_binary(field) do
try do
String.to_existing_atom("sort_#{field}")
rescue
ArgumentError -> :"sort_#{field}"
end
end
defp to_sort_id(field) when is_atom(field) do
:"sort_#{field}"
end
# Builds sort URL and pushes navigation patch
defp push_sort_url(socket, field, order) do
field_str =
if is_atom(field) do
Atom.to_string(field)
else
field
end
query_params = %{
"query" => socket.assigns.query,
"sort_field" => field_str,
"sort_order" => Atom.to_string(order)
}
new_path = ~p"/members?#{query_params}"
{:noreply,
push_patch(socket,
to: new_path,
replace: true
)}
end
# Loads members from the database with custom field values and applies search/sort filters.
#
# Process:
# 1. Builds base query with selected fields
# 2. Loads custom field values for visible custom fields (filtered at database level)
# 3. Applies search filter if provided
# 4. Applies sorting (database-level for regular fields, in-memory for custom fields)
#
# Performance Considerations:
# - Database-level filtering: Custom field values are filtered directly in the database
# using Ash relationship filters, reducing memory usage and improving performance.
# - In-memory sorting: Custom field sorting is done in memory after loading.
# This is suitable for small to medium datasets (<1000 members).
# For larger datasets, consider implementing database-level sorting or pagination.
# - No pagination: All matching members are loaded at once. For large result sets,
# consider implementing pagination (see Issue #165).
#
# Returns the socket with `:members` assigned.
defp load_members(socket, search_query) do
query =
Mv.Membership.Member
|> Ash.Query.new()
|> Ash.Query.select(@overview_fields)
# Load custom field values for visible custom fields
custom_field_ids_list = Enum.map(socket.assigns.custom_fields_visible, & &1.id)
query = load_custom_field_values(query, custom_field_ids_list)
# Apply the search filter first
query = apply_search_filter(query, search_query)
# Apply sorting based on current socket state
# For custom fields, we sort after loading
{query, sort_after_load} =
maybe_sort(
query,
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.custom_fields_visible
)
# Note: Using Ash.read! - errors will be handled by Phoenix LiveView
# This is appropriate for data loading in LiveViews
members = Ash.read!(query)
# Custom field values are already filtered at the database level in load_custom_field_values/2
# No need for in-memory filtering anymore
# Sort in memory if needed (for custom fields)
members =
if sort_after_load do
sort_members_in_memory(
members,
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.custom_fields_visible
)
else
members
end
assign(socket, :members, members)
end
# Load custom field values for the given custom field IDs
#
# Filters custom field values directly in the database using Ash relationship filters.
# This is more efficient than loading all values and filtering in memory.
#
# Performance: Database-level filtering reduces:
# - Memory usage (only visible custom field values are loaded)
# - Network transfer (less data from database to application)
# - Processing time (no need to iterate through all members and filter)
defp load_custom_field_values(query, []) do
query
end
defp load_custom_field_values(query, custom_field_ids) when length(custom_field_ids) > 0 do
# Filter custom field values at the database level using Ash relationship query
# This ensures only visible custom field values are loaded
custom_field_values_query =
Mv.Membership.CustomFieldValue
|> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids))
|> Ash.Query.load(custom_field: [:id, :name, :value_type])
query
|> Ash.Query.load(custom_field_values: custom_field_values_query)
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
|> Mv.Membership.Member.fuzzy_search(%{
query: 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
# Returns {query, sort_after_load} where sort_after_load is true if we need to sort in memory
defp maybe_sort(query, nil, _, _), do: {query, false}
defp maybe_sort(query, field, order, _custom_fields) when not is_nil(field) do
if custom_field_sort?(field) do
# Custom fields need to be sorted in memory after loading
{query, true}
else
# Only sort by atom fields (regular member fields) in database
if is_atom(field) do
{Ash.Query.sort(query, [{field, order}]), false}
else
{query, false}
end
end
end
defp maybe_sort(query, _, _, _), do: {query, false}
# Validate that a field is sortable
# Uses member fields from constants, but excludes fields that don't make sense to sort
# (e.g., :notes is too long, :paid is boolean and not very useful for sorting)
defp valid_sort_field?(field) when is_atom(field) do
# All member fields are sortable, but we exclude some that don't make sense
# :id is not in member_fields, but we don't want to sort by it anyway
non_sortable_fields = [:notes, :paid]
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
field in valid_fields or custom_field_sort?(field)
end
defp valid_sort_field?(field) when is_binary(field) do
custom_field_sort?(field)
end
defp valid_sort_field?(_), do: false
# Check if field is a custom field sort field (format: custom_field_<id>)
defp custom_field_sort?(field) when is_atom(field) do
field_str = Atom.to_string(field)
String.starts_with?(field_str, @custom_field_prefix)
end
defp custom_field_sort?(field) when is_binary(field) do
String.starts_with?(field, @custom_field_prefix)
end
defp custom_field_sort?(_), do: false
# Extracts the custom field ID from a sort field name.
#
# Sort fields for custom fields use the format: "custom_field_<id>"
# This function extracts the ID part.
#
# Examples:
# extract_custom_field_id("custom_field_123") -> "123"
# extract_custom_field_id(:custom_field_123) -> "123"
# extract_custom_field_id("first_name") -> nil
defp extract_custom_field_id(field) when is_atom(field) do
field_str = Atom.to_string(field)
extract_custom_field_id(field_str)
end
defp extract_custom_field_id(field) when is_binary(field) do
case String.split(field, @custom_field_prefix) do
["", id_str] -> id_str
_ -> nil
end
end
defp extract_custom_field_id(_), do: nil
# Sorts members in memory by a custom field value.
#
# Process:
# 1. Extracts custom field ID from sort field name
# 2. Finds the corresponding CustomField resource
# 3. Splits members into those with values and those without
# 4. Sorts members with values by the extracted value
# 5. Combines: sorted values first, then NULL/empty values at the end
#
# Performance Note:
# This function sorts in memory, which is suitable for small to medium datasets (<1000 members).
# For larger datasets, consider implementing database-level sorting or pagination.
#
# Parameters:
# - `members` - List of Member resources to sort
# - `field` - Sort field name (format: "custom_field_<id>" or atom)
# - `order` - Sort order (`:asc` or `:desc`)
# - `custom_fields` - List of visible CustomField resources
#
# Returns the sorted list of members.
defp sort_members_in_memory(members, field, order, custom_fields) do
custom_field_id_str = extract_custom_field_id(field)
case custom_field_id_str do
nil ->
members
id_str ->
sort_members_by_custom_field(members, id_str, order, custom_fields)
end
end
# Sorts members by a specific custom field ID
defp sort_members_by_custom_field(members, id_str, order, custom_fields) do
custom_field = find_custom_field_by_id(custom_fields, id_str)
case custom_field do
nil ->
members
cf ->
sort_members_with_custom_field(members, cf, order)
end
end
# Finds a custom field by matching its ID string
defp find_custom_field_by_id(custom_fields, id_str) do
Enum.find(custom_fields, fn cf ->
to_string(cf.id) == id_str
end)
end
# Sorts members that have a specific custom field
defp sort_members_with_custom_field(members, custom_field, order) do
# Split members into those with values and those without (NULL/empty)
{members_with_values, members_without_values} =
split_members_by_value_presence(members, custom_field)
# Sort members with values
sorted_with_values = sort_members_with_values(members_with_values, custom_field, order)
# Combine: sorted values first, then NULL/empty values at the end
sorted_with_values ++ members_without_values
end
# Splits members into those with values and those without
defp split_members_by_value_presence(members, custom_field) do
Enum.split_with(members, fn member ->
has_non_empty_value?(member, custom_field)
end)
end
# Checks if a member has a non-empty value for the custom field
defp has_non_empty_value?(member, custom_field) do
case get_custom_field_value(member, custom_field) do
nil ->
false
cfv ->
extracted = extract_sort_value(cfv.value, custom_field.value_type)
not empty_value?(extracted, custom_field.value_type)
end
end
# Sorts members that have values for the custom field
defp sort_members_with_values(members_with_values, custom_field, order) do
sorted =
Enum.sort_by(members_with_values, fn member ->
cfv = get_custom_field_value(member, custom_field)
extracted = extract_sort_value(cfv.value, custom_field.value_type)
normalize_sort_value(extracted, order)
end)
# For DESC, reverse only the members with values
if order == :desc do
Enum.reverse(sorted)
else
sorted
end
end
# Extracts a sortable value from a custom field value based on its type.
#
# Handles different value formats:
# - `%Ash.Union{}` - Extracts value and type from union
# - Direct values - Returns as-is for primitive types
#
# Returns the extracted value suitable for sorting.
defp extract_sort_value(%Ash.Union{value: value, type: type}, _expected_type) do
extract_sort_value(value, type)
end
defp extract_sort_value(value, :string) when is_binary(value), do: value
defp extract_sort_value(value, :integer) when is_integer(value), do: value
defp extract_sort_value(value, :boolean) when is_boolean(value), do: value
defp extract_sort_value(%Date{} = date, :date), do: date
defp extract_sort_value(value, :email) when is_binary(value), do: value
defp extract_sort_value(value, _type), do: to_string(value)
# Check if a value is considered empty (NULL or empty string)
defp empty_value?(value, :string) when is_binary(value) do
String.trim(value) == ""
end
defp empty_value?(value, :email) when is_binary(value) do
String.trim(value) == ""
end
defp empty_value?(_value, _type), do: false
# Normalize sort value for DESC order
# For DESC, we sort ascending first, then reverse the list
# This function is kept for consistency but doesn't need to invert values
defp normalize_sort_value(value, _order), do: value
# Updates sort field and order from URL parameters if present.
#
# Validates the sort field and order, falling back to defaults if invalid.
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
# Determine sort field from URL parameter, validating against allowed fields
defp determine_field(default, ""), do: default
defp determine_field(default, nil), do: default
# Determines the valid sort field from a URL parameter.
#
# Validates the field against allowed sort fields (regular member fields or custom fields).
# Falls back to default if the field is invalid.
#
# Parameters:
# - `default` - Default field to use if validation fails
# - `sf` - Sort field from URL (can be atom, string, nil, or empty string)
#
# Returns a valid sort field (atom or string for custom fields).
defp determine_field(default, sf) when is_binary(sf) do
# Check if it's a custom field sort (starts with "custom_field_")
if custom_field_sort?(sf) do
if valid_sort_field?(sf), do: sf, else: default
else
# Try to convert to atom for regular fields
try do
atom = String.to_existing_atom(sf)
if valid_sort_field?(atom), do: atom, else: default
rescue
ArgumentError -> default
end
end
end
defp determine_field(default, sf) when is_atom(sf) do
if valid_sort_field?(sf), do: sf, else: default
end
defp determine_field(default, _), do: default
# Determines the valid sort order from a URL parameter.
#
# Validates that the order is either "asc" or "desc", falling back to default if invalid.
#
# Parameters:
# - `default` - Default order to use if validation fails
# - `so` - Sort order from URL (string, atom, nil, or empty string)
#
# Returns `:asc` or `:desc`.
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
# -------------------------------------------------------------
# Helper Functions for Custom Field Values
# -------------------------------------------------------------
# Retrieves the custom field value for a specific member and custom field.
#
# Searches through the member's `custom_field_values` relationship to find
# the value matching the given custom field.
#
# Returns:
# - `%CustomFieldValue{}` if found
# - `nil` if not found or if member has no custom field values
#
# Examples:
# get_custom_field_value(member, custom_field) -> %CustomFieldValue{...}
# get_custom_field_value(member, non_existent_field) -> nil
def get_custom_field_value(member, custom_field) do
case member.custom_field_values do
nil ->
nil
values when is_list(values) ->
Enum.find(values, fn cfv ->
cfv.custom_field_id == custom_field.id or
(cfv.custom_field && cfv.custom_field.id == custom_field.id)
end)
_ ->
nil
end
end
# Gets the list of member fields that should be visible in the overview.
#
# Reads the visibility configuration from Settings and returns only the fields
# where show_in_overview is true. Fields not configured in settings default to true.
#
# Performance: This function uses the already-loaded settings to avoid N+1 queries.
# Settings should be loaded once in mount/3 and passed to this function.
#
# Parameters:
# - `settings` - The settings struct loaded from the database
#
# Returns a list of atoms representing visible member field names.
#
# Fields are read from the global Constants module.
@spec get_visible_member_fields(map()) :: [atom()]
defp get_visible_member_fields(settings) do
# Get all eligible fields from the global constants
all_fields = Mv.Constants.member_fields()
# JSONB stores keys as strings
visibility_config = settings.member_field_visibility || %{}
# Filter to only return visible fields
Enum.filter(all_fields, fn field ->
Map.get(visibility_config, Atom.to_string(field), true)
end)
end
end