Calculate selected_count, any_selected? and mailto_bcc once in assigns instead of recalculating Enum.any? and Enum.count multiple times in template. This improves render performance and makes the template code more readable.
1152 lines
38 KiB
Elixir
1152 lines
38 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
|
|
- `copy_emails` - Copy email addresses of selected members to clipboard
|
|
|
|
## 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
|
|
alias MvWeb.Helpers.DateFormatter
|
|
alias MvWeb.MemberLive.Index.FieldSelection
|
|
alias MvWeb.MemberLive.Index.FieldVisibility
|
|
|
|
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
|
|
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
|
|
|
# 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,
|
|
payment filter, 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 (for display)
|
|
# 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 ALL custom fields for the dropdown (to show all available fields)
|
|
all_custom_fields =
|
|
Mv.Membership.CustomField
|
|
|> 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
|
|
|
|
# Load user field selection from session
|
|
session_selection = FieldSelection.get_from_session(session)
|
|
|
|
# Get all available fields (for dropdown - includes ALL custom fields)
|
|
all_available_fields = FieldVisibility.get_all_available_fields(all_custom_fields)
|
|
|
|
# Merge session selection with global settings for initial state (use all_custom_fields)
|
|
initial_selection =
|
|
FieldVisibility.merge_with_global_settings(
|
|
session_selection,
|
|
settings,
|
|
all_custom_fields
|
|
)
|
|
|
|
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(:paid_filter, nil)
|
|
|> assign(:selected_members, MapSet.new())
|
|
|> assign(:settings, settings)
|
|
|> assign(:custom_fields_visible, custom_fields_visible)
|
|
|> assign(:all_custom_fields, all_custom_fields)
|
|
|> assign(:all_available_fields, all_available_fields)
|
|
|> assign(:user_field_selection, initial_selection)
|
|
|> assign(
|
|
:member_fields_visible,
|
|
FieldVisibility.get_visible_member_fields(initial_selection)
|
|
)
|
|
|
|
# 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 MapSet.member?(socket.assigns.selected_members, id) do
|
|
MapSet.delete(socket.assigns.selected_members, id)
|
|
else
|
|
MapSet.put(socket.assigns.selected_members, id)
|
|
end
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:selected_members, selected)
|
|
|> update_selection_assigns()}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("select_all", _params, socket) do
|
|
all_ids = socket.assigns.members |> Enum.map(& &1.id) |> MapSet.new()
|
|
|
|
selected =
|
|
if MapSet.equal?(socket.assigns.selected_members, all_ids) do
|
|
MapSet.new()
|
|
else
|
|
all_ids
|
|
end
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:selected_members, selected)
|
|
|> update_selection_assigns()}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("copy_emails", _params, socket) do
|
|
selected_ids = socket.assigns.selected_members
|
|
|
|
# Filter members that are in the selection and have email addresses
|
|
formatted_emails = format_selected_member_emails(socket.assigns.members, selected_ids)
|
|
email_count = length(formatted_emails)
|
|
|
|
cond do
|
|
MapSet.size(selected_ids) == 0 ->
|
|
{:noreply, put_flash(socket, :error, gettext("No members selected"))}
|
|
|
|
email_count == 0 ->
|
|
{:noreply, put_flash(socket, :error, gettext("No email addresses found"))}
|
|
|
|
true ->
|
|
# RFC 5322 uses comma as separator for email address lists
|
|
email_string = Enum.join(formatted_emails, ", ")
|
|
|
|
socket =
|
|
socket
|
|
|> push_event("copy_to_clipboard", %{text: email_string})
|
|
|> put_flash(
|
|
:success,
|
|
ngettext(
|
|
"Copied %{count} email address to clipboard",
|
|
"Copied %{count} email addresses to clipboard",
|
|
email_count,
|
|
count: email_count
|
|
)
|
|
)
|
|
|> put_flash(
|
|
:warning,
|
|
gettext("Tip: Paste email addresses into the BCC field for privacy compliance")
|
|
)
|
|
|
|
{:noreply, socket}
|
|
end
|
|
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
|
|
- `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent
|
|
- `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent
|
|
"""
|
|
@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 =
|
|
socket
|
|
|> assign(:query, q)
|
|
|> load_members()
|
|
|> update_selection_assigns()
|
|
|
|
existing_field_query = socket.assigns.sort_field
|
|
existing_sort_query = socket.assigns.sort_order
|
|
|
|
# Build the URL with queries
|
|
query_params =
|
|
build_query_params(q, existing_field_query, existing_sort_query, socket.assigns.paid_filter)
|
|
|
|
# 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
|
|
|
|
@impl true
|
|
def handle_info({:payment_filter_changed, filter}, socket) do
|
|
socket =
|
|
socket
|
|
|> assign(:paid_filter, filter)
|
|
|> load_members()
|
|
|> update_selection_assigns()
|
|
|
|
# Build the URL with all params including new filter
|
|
query_params =
|
|
build_query_params(
|
|
socket.assigns.query,
|
|
socket.assigns.sort_field,
|
|
socket.assigns.sort_order,
|
|
filter
|
|
)
|
|
|
|
new_path = ~p"/members?#{query_params}"
|
|
|
|
{:noreply,
|
|
push_patch(socket,
|
|
to: new_path,
|
|
replace: true
|
|
)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:field_toggled, field_string, visible}, socket) do
|
|
# Update user field selection
|
|
new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible)
|
|
|
|
# Save to session (cookie will be saved on next page load via handle_params)
|
|
socket = update_session_field_selection(socket, new_selection)
|
|
|
|
# Merge with global settings (use all_custom_fields to allow enabling globally hidden fields)
|
|
final_selection =
|
|
FieldVisibility.merge_with_global_settings(
|
|
new_selection,
|
|
socket.assigns.settings,
|
|
socket.assigns.all_custom_fields
|
|
)
|
|
|
|
# Get visible fields
|
|
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
|
|
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:user_field_selection, final_selection)
|
|
|> assign(:member_fields_visible, visible_member_fields)
|
|
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
|
|> load_members()
|
|
|> prepare_dynamic_cols()
|
|
|> update_selection_assigns()
|
|
|> push_field_selection_url()
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:fields_selected, selection}, socket) do
|
|
# Save to session
|
|
socket = update_session_field_selection(socket, selection)
|
|
|
|
# Merge with global settings (use all_custom_fields for merging)
|
|
final_selection =
|
|
FieldVisibility.merge_with_global_settings(
|
|
selection,
|
|
socket.assigns.settings,
|
|
socket.assigns.all_custom_fields
|
|
)
|
|
|
|
# Get visible fields
|
|
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
|
|
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:user_field_selection, final_selection)
|
|
|> assign(:member_fields_visible, visible_member_fields)
|
|
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
|
|> load_members()
|
|
|> prepare_dynamic_cols()
|
|
|> update_selection_assigns()
|
|
|> push_field_selection_url()
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
# -----------------------------------------------------------------
|
|
# Handle Params from the URL
|
|
# -----------------------------------------------------------------
|
|
@doc """
|
|
Handles URL parameter changes.
|
|
|
|
Parses query parameters for search query, sort field, sort order, and payment filter, and field selection,
|
|
then loads members accordingly. This enables bookmarkable URLs and
|
|
browser back/forward navigation.
|
|
"""
|
|
@impl true
|
|
def handle_params(params, _url, socket) do
|
|
# Parse field selection from URL
|
|
url_selection = FieldSelection.parse_from_url(params)
|
|
|
|
# Merge with session selection (URL has priority)
|
|
merged_selection =
|
|
FieldSelection.merge_sources(
|
|
url_selection,
|
|
socket.assigns.user_field_selection,
|
|
%{}
|
|
)
|
|
|
|
# Merge with global settings (use all_custom_fields for merging)
|
|
final_selection =
|
|
FieldVisibility.merge_with_global_settings(
|
|
merged_selection,
|
|
socket.assigns.settings,
|
|
socket.assigns.all_custom_fields
|
|
)
|
|
|
|
# Get visible fields
|
|
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
|
|
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
|
|
|
|
socket =
|
|
socket
|
|
|> maybe_update_search(params)
|
|
|> maybe_update_sort(params)
|
|
|> maybe_update_paid_filter(params)
|
|
|> assign(:query, params["query"])
|
|
|> assign(:user_field_selection, final_selection)
|
|
|> assign(:member_fields_visible, visible_member_fields)
|
|
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
|
|> load_members()
|
|
|> prepare_dynamic_cols()
|
|
|> update_selection_assigns()
|
|
|
|
{: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
|
|
#
|
|
# Only includes custom fields that are visible according to user field selection.
|
|
#
|
|
# Returns the socket with `:dynamic_cols` assigned.
|
|
defp prepare_dynamic_cols(socket) do
|
|
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
|
|
|
# Use all_custom_fields to allow users to enable globally hidden custom fields
|
|
dynamic_cols =
|
|
socket.assigns.all_custom_fields
|
|
|> Enum.filter(fn custom_field -> custom_field.id in visible_custom_field_ids end)
|
|
|> Enum.map(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 =
|
|
build_query_params(
|
|
socket.assigns.query,
|
|
field_str,
|
|
Atom.to_string(order),
|
|
socket.assigns.paid_filter
|
|
)
|
|
|
|
new_path = ~p"/members?#{query_params}"
|
|
|
|
{:noreply,
|
|
push_patch(socket,
|
|
to: new_path,
|
|
replace: true
|
|
)}
|
|
end
|
|
|
|
# Builds query parameters including field selection
|
|
defp build_query_params(socket, base_params) do
|
|
# Use query from base_params if provided, otherwise fall back to socket.assigns.query
|
|
query_value = Map.get(base_params, "query") || socket.assigns.query || ""
|
|
|
|
base_params
|
|
|> Map.put("query", query_value)
|
|
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
|
|
end
|
|
|
|
# Adds field selection to query params if present
|
|
defp maybe_add_field_selection(params, nil), do: params
|
|
|
|
defp maybe_add_field_selection(params, selection) when is_map(selection) do
|
|
fields_param = FieldSelection.to_url_param(selection)
|
|
if fields_param != "", do: Map.put(params, "fields", fields_param), else: params
|
|
end
|
|
|
|
defp maybe_add_field_selection(params, _), do: params
|
|
|
|
# Pushes URL with updated field selection
|
|
defp push_field_selection_url(socket) do
|
|
base_params = %{
|
|
"sort_field" => field_to_string(socket.assigns.sort_field),
|
|
"sort_order" => Atom.to_string(socket.assigns.sort_order)
|
|
}
|
|
|
|
# Include paid_filter if set
|
|
base_params =
|
|
case socket.assigns.paid_filter do
|
|
nil -> base_params
|
|
:paid -> Map.put(base_params, "paid_filter", "paid")
|
|
:not_paid -> Map.put(base_params, "paid_filter", "not_paid")
|
|
end
|
|
|
|
query_params = build_query_params(socket, base_params)
|
|
new_path = ~p"/members?#{query_params}"
|
|
|
|
push_patch(socket, to: new_path, replace: true)
|
|
end
|
|
|
|
# Converts field to string
|
|
defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
|
|
defp field_to_string(field) when is_binary(field), do: field
|
|
|
|
# Updates session field selection (stored in socket for now, actual session update via controller)
|
|
defp update_session_field_selection(socket, selection) do
|
|
# Store in socket for now - actual session persistence would require a controller
|
|
# This is a placeholder for future session persistence
|
|
assign(socket, :user_field_selection, selection)
|
|
end
|
|
|
|
# Builds URL query parameters map including all filter/sort state.
|
|
# Converts paid_filter atom to string for URL.
|
|
defp build_query_params(query, sort_field, sort_order, paid_filter) do
|
|
field_str =
|
|
if is_atom(sort_field) do
|
|
Atom.to_string(sort_field)
|
|
else
|
|
sort_field
|
|
end
|
|
|
|
order_str =
|
|
if is_atom(sort_order) do
|
|
Atom.to_string(sort_order)
|
|
else
|
|
sort_order
|
|
end
|
|
|
|
base_params = %{
|
|
"query" => query,
|
|
"sort_field" => field_str,
|
|
"sort_order" => order_str
|
|
}
|
|
|
|
# Only add paid_filter to URL if it's set
|
|
case paid_filter do
|
|
nil -> base_params
|
|
:paid -> Map.put(base_params, "paid_filter", "paid")
|
|
:not_paid -> Map.put(base_params, "paid_filter", "not_paid")
|
|
end
|
|
end
|
|
|
|
# Loads members from the database with custom field values and applies search/sort/payment 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 payment status filter if set
|
|
# 5. 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) do
|
|
search_query = socket.assigns.query
|
|
|
|
query =
|
|
Mv.Membership.Member
|
|
|> Ash.Query.new()
|
|
|> Ash.Query.select(@overview_fields)
|
|
|
|
# Load custom field values for visible custom fields (based on user selection)
|
|
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
|
query = load_custom_field_values(query, visible_custom_field_ids)
|
|
|
|
# Apply the search filter first
|
|
query = apply_search_filter(query, search_query)
|
|
|
|
# Apply payment status filter
|
|
query = apply_paid_filter(query, socket.assigns.paid_filter)
|
|
|
|
# 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) 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
|
|
|
|
# Applies payment status filter to the query.
|
|
#
|
|
# Filter values:
|
|
# - nil: No filter, return all members
|
|
# - :paid: Only members with paid == true
|
|
# - :not_paid: Members with paid == false or paid == nil (not paid)
|
|
defp apply_paid_filter(query, nil), do: query
|
|
|
|
defp apply_paid_filter(query, :paid) do
|
|
Ash.Query.filter(query, expr(paid == true))
|
|
end
|
|
|
|
defp apply_paid_filter(query, :not_paid) do
|
|
# Include both false and nil as "not paid"
|
|
# Note: paid != true doesn't work correctly with NULL values in SQL
|
|
Ash.Query.filter(query, expr(paid == false or is_nil(paid)))
|
|
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
|
|
|
|
# Extracts custom field IDs from visible custom field strings
|
|
# Format: "custom_field_<id>" -> <id>
|
|
defp extract_custom_field_ids(visible_custom_fields) do
|
|
Enum.map(visible_custom_fields, fn field_string ->
|
|
case String.split(field_string, @custom_field_prefix) do
|
|
["", id] -> id
|
|
_ -> nil
|
|
end
|
|
end)
|
|
|> Enum.filter(&(&1 != nil))
|
|
end
|
|
|
|
# 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
|
|
|
|
# Updates paid filter from URL parameters if present.
|
|
#
|
|
# Validates the filter value, falling back to nil (no filter) if invalid.
|
|
defp maybe_update_paid_filter(socket, %{"paid_filter" => filter_str}) do
|
|
filter = determine_paid_filter(filter_str)
|
|
assign(socket, :paid_filter, filter)
|
|
end
|
|
|
|
defp maybe_update_paid_filter(socket, _params) do
|
|
# Reset filter if not in URL params
|
|
assign(socket, :paid_filter, nil)
|
|
end
|
|
|
|
# Determines valid paid filter from URL parameter.
|
|
#
|
|
# SECURITY: This function whitelists allowed filter values. Only "paid" and "not_paid"
|
|
# are accepted - all other input (including malicious strings) falls back to nil.
|
|
# This ensures no raw user input is ever passed to Ash.Query.filter/2, following
|
|
# Ash's security recommendation to never pass untrusted input directly to filters.
|
|
defp determine_paid_filter("paid"), do: :paid
|
|
defp determine_paid_filter("not_paid"), do: :not_paid
|
|
defp determine_paid_filter(_), do: nil
|
|
|
|
# -------------------------------------------------------------
|
|
# 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
|
|
|
|
# Filters selected members with email addresses and formats them.
|
|
# Returns a list of formatted email strings in the format "First Last <email>".
|
|
# Used by both copy_emails and mailto links.
|
|
def format_selected_member_emails(members, selected_members) do
|
|
members
|
|
|> Enum.filter(fn member ->
|
|
MapSet.member?(selected_members, member.id) && member.email && member.email != ""
|
|
end)
|
|
|> Enum.map(&format_member_email/1)
|
|
end
|
|
|
|
@doc """
|
|
Returns a JS command to toggle member selection when clicking the checkbox column.
|
|
|
|
Used as `col_click` handler to ensure clicking anywhere in the checkbox column
|
|
toggles the checkbox instead of navigating to the member details.
|
|
"""
|
|
def checkbox_column_click(member) do
|
|
JS.push("select_member", value: %{id: member.id})
|
|
end
|
|
|
|
# Formats a member's email in the format "First Last <email>"
|
|
# Used for copy_emails feature and mailto links to create email-client-friendly format.
|
|
def format_member_email(member) do
|
|
first_name = member.first_name || ""
|
|
last_name = member.last_name || ""
|
|
|
|
name =
|
|
[first_name, last_name]
|
|
|> Enum.filter(&(&1 != ""))
|
|
|> Enum.join(" ")
|
|
|
|
if name == "" do
|
|
member.email
|
|
else
|
|
"#{name} <#{member.email}>"
|
|
end
|
|
end
|
|
|
|
# Public helper function to format dates for use in templates
|
|
def format_date(date), do: DateFormatter.format_date(date)
|
|
|
|
# Updates selection-related assigns (selected_count, any_selected?, mailto_bcc)
|
|
# to avoid recalculating Enum.any? and Enum.count multiple times in templates.
|
|
defp update_selection_assigns(socket) do
|
|
members = socket.assigns.members
|
|
selected_members = socket.assigns.selected_members
|
|
|
|
selected_count =
|
|
Enum.count(members, &MapSet.member?(selected_members, &1.id))
|
|
|
|
any_selected? =
|
|
Enum.any?(members, &MapSet.member?(selected_members, &1.id))
|
|
|
|
mailto_bcc =
|
|
if any_selected? do
|
|
format_selected_member_emails(members, selected_members)
|
|
|> Enum.join(", ")
|
|
else
|
|
""
|
|
end
|
|
|
|
socket
|
|
|> assign(:selected_count, selected_count)
|
|
|> assign(:any_selected?, any_selected?)
|
|
|> assign(:mailto_bcc, mailto_bcc)
|
|
end
|
|
end
|