1444 lines
45 KiB
Elixir
1444 lines
45 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
|
||
require Logger
|
||
import Ash.Expr
|
||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||
|
||
alias Mv.Membership
|
||
alias MvWeb.Helpers.DateFormatter
|
||
alias MvWeb.MemberLive.Index.FieldSelection
|
||
alias MvWeb.MemberLive.Index.FieldVisibility
|
||
alias MvWeb.MemberLive.Index.Formatter
|
||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||
|
||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||
@boolean_filter_prefix Mv.Constants.boolean_filter_prefix()
|
||
|
||
# Maximum number of boolean custom field filters allowed per request (DoS protection)
|
||
@max_boolean_filters Mv.Constants.max_boolean_filters()
|
||
|
||
# Maximum length of UUID string (36 characters including hyphens)
|
||
@max_uuid_length Mv.Constants.max_uuid_length()
|
||
|
||
# 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)
|
||
# Errors in mount are handled by Phoenix LiveView and result in a 500 error page.
|
||
# This is appropriate for initialization errors that should be visible to the user.
|
||
actor = current_actor(socket)
|
||
|
||
custom_fields_visible =
|
||
Mv.Membership.CustomField
|
||
|> Ash.Query.filter(expr(show_in_overview == true))
|
||
|> Ash.Query.sort(name: :asc)
|
||
|> Ash.read!(actor: actor)
|
||
|
||
# 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!(actor: actor)
|
||
|
||
# Load boolean custom fields (filtered and sorted from all_custom_fields)
|
||
boolean_custom_fields =
|
||
all_custom_fields
|
||
|> Enum.filter(&(&1.value_type == :boolean))
|
||
|> Enum.sort_by(& &1.name, :asc)
|
||
|
||
# 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)
|
||
|
||
# FIX: ensure dropdown doesn’t show duplicate fields (e.g. membership fee status twice)
|
||
all_available_fields =
|
||
all_custom_fields
|
||
|> FieldVisibility.get_all_available_fields()
|
||
|> dedupe_available_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(:cycle_status_filter, nil)
|
||
|> assign(:boolean_custom_field_filters, %{})
|
||
|> assign(:selected_members, MapSet.new())
|
||
|> assign(:settings, settings)
|
||
|> assign(:custom_fields_visible, custom_fields_visible)
|
||
|> assign(:all_custom_fields, all_custom_fields)
|
||
|> assign(:boolean_custom_fields, boolean_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)
|
||
)
|
||
|> assign(
|
||
:member_fields_visible_db,
|
||
FieldVisibility.get_visible_member_fields_db(initial_selection)
|
||
)
|
||
|> assign(
|
||
:member_fields_visible_computed,
|
||
FieldVisibility.get_visible_member_fields_computed(initial_selection)
|
||
)
|
||
|> assign(:show_current_cycle, false)
|
||
|> assign(:membership_fee_status_filter, nil)
|
||
|> assign_export_payload()
|
||
|
||
{: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
|
||
actor = current_actor(socket)
|
||
|
||
case Ash.get(Mv.Membership.Member, id, actor: actor) do
|
||
{:ok, member} ->
|
||
case Ash.destroy(member, actor: actor) do
|
||
:ok ->
|
||
updated_members = Enum.reject(socket.assigns.members, &(&1.id == id))
|
||
|
||
{:noreply,
|
||
socket
|
||
|> assign(:members, updated_members)
|
||
|> put_flash(:info, gettext("Member deleted successfully"))}
|
||
|
||
{:error, %Ash.Error.Forbidden{}} ->
|
||
{:noreply,
|
||
put_flash(
|
||
socket,
|
||
:error,
|
||
gettext("You do not have permission to delete this member")
|
||
)}
|
||
|
||
{:error, error} ->
|
||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||
end
|
||
|
||
{:error, %Ash.Error.Query.NotFound{}} ->
|
||
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
|
||
|
||
{:error, %Ash.Error.Forbidden{} = _error} ->
|
||
{:noreply,
|
||
put_flash(socket, :error, gettext("You do not have permission to access this member"))}
|
||
|
||
{:error, error} ->
|
||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||
end
|
||
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("toggle_cycle_view", _params, socket) do
|
||
new_show_current = !socket.assigns.show_current_cycle
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:show_current_cycle, new_show_current)
|
||
|> load_members()
|
||
|> update_selection_assigns()
|
||
|
||
query_params =
|
||
build_query_params(
|
||
socket.assigns.query,
|
||
socket.assigns.sort_field,
|
||
socket.assigns.sort_order,
|
||
socket.assigns.cycle_status_filter,
|
||
new_show_current,
|
||
socket.assigns.boolean_custom_field_filters
|
||
)
|
||
|
||
new_path = ~p"/members?#{query_params}"
|
||
|
||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||
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
|
||
|
||
# Helper to format errors for display
|
||
defp format_error(%Ash.Error.Invalid{errors: errors}) do
|
||
error_messages =
|
||
Enum.map(errors, fn error ->
|
||
case error do
|
||
%{field: field, message: message} -> "#{field}: #{message}"
|
||
%{message: message} -> message
|
||
_ -> inspect(error)
|
||
end
|
||
end)
|
||
|
||
Enum.join(error_messages, ", ")
|
||
end
|
||
|
||
defp format_error(error), do: inspect(error)
|
||
|
||
# -----------------------------------------------------------------
|
||
# 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)
|
||
old_field = socket.assigns.sort_field
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:sort_field, new_field)
|
||
|> assign(:sort_order, new_order)
|
||
|> update_sort_components(old_field, new_field, new_order)
|
||
|> load_members()
|
||
|> update_selection_assigns()
|
||
|
||
# URL sync
|
||
query_params =
|
||
build_query_params(
|
||
socket.assigns.query,
|
||
export_sort_field(socket.assigns.sort_field),
|
||
export_sort_order(socket.assigns.sort_order),
|
||
socket.assigns.cycle_status_filter,
|
||
socket.assigns.show_current_cycle,
|
||
socket.assigns.boolean_custom_field_filters
|
||
)
|
||
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
|
||
|
||
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_info({:search_changed, q}, socket) do
|
||
socket =
|
||
socket
|
||
|> assign(:query, q)
|
||
|> load_members()
|
||
|> update_selection_assigns()
|
||
|
||
query_params =
|
||
build_query_params(
|
||
q,
|
||
socket.assigns.sort_field,
|
||
socket.assigns.sort_order,
|
||
socket.assigns.cycle_status_filter,
|
||
socket.assigns.show_current_cycle,
|
||
socket.assigns.boolean_custom_field_filters
|
||
)
|
||
|
||
new_path = ~p"/members?#{query_params}"
|
||
|
||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_info({:payment_filter_changed, filter}, socket) do
|
||
socket =
|
||
socket
|
||
|> assign(:cycle_status_filter, filter)
|
||
|> load_members()
|
||
|> update_selection_assigns()
|
||
|
||
query_params =
|
||
build_query_params(
|
||
socket.assigns.query,
|
||
socket.assigns.sort_field,
|
||
socket.assigns.sort_order,
|
||
filter,
|
||
socket.assigns.show_current_cycle,
|
||
socket.assigns.boolean_custom_field_filters
|
||
)
|
||
|
||
new_path = ~p"/members?#{query_params}"
|
||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_info({:boolean_filter_changed, custom_field_id_str, filter_value}, socket) do
|
||
updated_filters =
|
||
if filter_value == nil do
|
||
Map.delete(socket.assigns.boolean_custom_field_filters, custom_field_id_str)
|
||
else
|
||
Map.put(socket.assigns.boolean_custom_field_filters, custom_field_id_str, filter_value)
|
||
end
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:boolean_custom_field_filters, updated_filters)
|
||
|> load_members()
|
||
|> update_selection_assigns()
|
||
|
||
query_params =
|
||
build_query_params(
|
||
socket.assigns.query,
|
||
socket.assigns.sort_field,
|
||
socket.assigns.sort_order,
|
||
socket.assigns.cycle_status_filter,
|
||
socket.assigns.show_current_cycle,
|
||
updated_filters
|
||
)
|
||
|
||
new_path = ~p"/members?#{query_params}"
|
||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
|
||
socket =
|
||
socket
|
||
|> assign(:cycle_status_filter, cycle_status_filter)
|
||
|> assign(:boolean_custom_field_filters, boolean_filters)
|
||
|> load_members()
|
||
|> update_selection_assigns()
|
||
|
||
query_params =
|
||
build_query_params(
|
||
socket.assigns.query,
|
||
socket.assigns.sort_field,
|
||
socket.assigns.sort_order,
|
||
cycle_status_filter,
|
||
socket.assigns.show_current_cycle,
|
||
boolean_filters
|
||
)
|
||
|
||
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
|
||
new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible)
|
||
socket = update_session_field_selection(socket, new_selection)
|
||
|
||
final_selection =
|
||
FieldVisibility.merge_with_global_settings(
|
||
new_selection,
|
||
socket.assigns.settings,
|
||
socket.assigns.all_custom_fields
|
||
)
|
||
|
||
visible_member_fields =
|
||
final_selection
|
||
|> FieldVisibility.get_visible_member_fields()
|
||
|> Enum.uniq()
|
||
|
||
visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)
|
||
|
||
visible_member_fields_computed =
|
||
FieldVisibility.get_visible_member_fields_computed(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(:member_fields_visible_db, visible_member_fields_db)
|
||
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|
||
|> 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
|
||
socket = update_session_field_selection(socket, selection)
|
||
|
||
final_selection =
|
||
FieldVisibility.merge_with_global_settings(
|
||
selection,
|
||
socket.assigns.settings,
|
||
socket.assigns.all_custom_fields
|
||
)
|
||
|
||
visible_member_fields =
|
||
final_selection
|
||
|> FieldVisibility.get_visible_member_fields()
|
||
|> Enum.uniq()
|
||
|
||
visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)
|
||
|
||
visible_member_fields_computed =
|
||
FieldVisibility.get_visible_member_fields_computed(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(:member_fields_visible_db, visible_member_fields_db)
|
||
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|
||
|> 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
|
||
# -----------------------------------------------------------------
|
||
|
||
@impl true
|
||
def handle_params(params, _url, socket) do
|
||
prev_sig = build_signature(socket)
|
||
url_selection = FieldSelection.parse_from_url(params)
|
||
|
||
merged_selection =
|
||
FieldSelection.merge_sources(
|
||
url_selection,
|
||
socket.assigns.user_field_selection,
|
||
%{}
|
||
)
|
||
|
||
final_selection =
|
||
FieldVisibility.merge_with_global_settings(
|
||
merged_selection,
|
||
socket.assigns.settings,
|
||
socket.assigns.all_custom_fields
|
||
)
|
||
|
||
visible_member_fields =
|
||
final_selection
|
||
|> FieldVisibility.get_visible_member_fields()
|
||
|> Enum.uniq()
|
||
|
||
visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)
|
||
|
||
visible_member_fields_computed =
|
||
FieldVisibility.get_visible_member_fields_computed(final_selection)
|
||
|
||
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
|
||
|
||
socket =
|
||
socket
|
||
|> maybe_update_search(params)
|
||
|> maybe_update_sort(params)
|
||
|> maybe_update_cycle_status_filter(params)
|
||
|> maybe_update_boolean_filters(params)
|
||
|> maybe_update_show_current_cycle(params)
|
||
|> assign(:query, params["query"])
|
||
|> assign(:user_field_selection, final_selection)
|
||
|> assign(:member_fields_visible, visible_member_fields)
|
||
|> assign(:member_fields_visible_db, visible_member_fields_db)
|
||
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|
||
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
||
|
||
next_sig = build_signature(socket)
|
||
|
||
socket =
|
||
if prev_sig == next_sig && Map.has_key?(socket.assigns, :members) do
|
||
socket
|
||
|> prepare_dynamic_cols()
|
||
|> update_selection_assigns()
|
||
else
|
||
socket
|
||
|> load_members()
|
||
|> prepare_dynamic_cols()
|
||
|> update_selection_assigns()
|
||
end
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
defp build_signature(socket) do
|
||
{
|
||
socket.assigns.query,
|
||
socket.assigns.sort_field,
|
||
socket.assigns.sort_order,
|
||
socket.assigns.cycle_status_filter,
|
||
socket.assigns.show_current_cycle,
|
||
socket.assigns.boolean_custom_field_filters,
|
||
socket.assigns.user_field_selection,
|
||
socket.assigns[:visible_custom_field_ids] || []
|
||
}
|
||
end
|
||
|
||
defp prepare_dynamic_cols(socket) do
|
||
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
||
visible_set = MapSet.new(visible_custom_field_ids)
|
||
|
||
dynamic_cols =
|
||
socket.assigns.all_custom_fields
|
||
|> Enum.filter(fn custom_field ->
|
||
MapSet.member?(visible_set, to_string(custom_field.id))
|
||
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
|
||
|
||
# -------------------------------------------------------------
|
||
# Sorting
|
||
# -------------------------------------------------------------
|
||
|
||
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
|
||
|
||
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)
|
||
|
||
send_update(MvWeb.Components.SortHeaderComponent,
|
||
id: active_id,
|
||
sort_field: new_field,
|
||
sort_order: new_order
|
||
)
|
||
|
||
send_update(MvWeb.Components.SortHeaderComponent,
|
||
id: old_id,
|
||
sort_field: new_field,
|
||
sort_order: new_order
|
||
)
|
||
|
||
socket
|
||
end
|
||
|
||
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}"
|
||
|
||
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.cycle_status_filter,
|
||
socket.assigns.show_current_cycle,
|
||
socket.assigns.boolean_custom_field_filters
|
||
)
|
||
|
||
new_path = ~p"/members?#{query_params}"
|
||
|
||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||
end
|
||
|
||
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
|
||
|
||
defp push_field_selection_url(socket) do
|
||
query_params =
|
||
build_query_params(
|
||
socket.assigns.query,
|
||
socket.assigns.sort_field,
|
||
socket.assigns.sort_order,
|
||
socket.assigns.cycle_status_filter,
|
||
socket.assigns.show_current_cycle,
|
||
socket.assigns.boolean_custom_field_filters
|
||
)
|
||
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
|
||
|
||
new_path = ~p"/members?#{query_params}"
|
||
push_patch(socket, to: new_path, replace: true)
|
||
end
|
||
|
||
defp update_session_field_selection(socket, selection) do
|
||
assign(socket, :user_field_selection, selection)
|
||
end
|
||
|
||
defp build_query_params(
|
||
query,
|
||
sort_field,
|
||
sort_order,
|
||
cycle_status_filter,
|
||
show_current_cycle,
|
||
boolean_filters
|
||
) 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
|
||
}
|
||
|
||
base_params =
|
||
case cycle_status_filter do
|
||
nil -> base_params
|
||
:paid -> Map.put(base_params, "cycle_status_filter", "paid")
|
||
:unpaid -> Map.put(base_params, "cycle_status_filter", "unpaid")
|
||
end
|
||
|
||
base_params =
|
||
if show_current_cycle do
|
||
Map.put(base_params, "show_current_cycle", "true")
|
||
else
|
||
base_params
|
||
end
|
||
|
||
Enum.reduce(boolean_filters, base_params, fn {custom_field_id, filter_value}, acc ->
|
||
param_key = "#{@boolean_filter_prefix}#{custom_field_id}"
|
||
param_value = if filter_value == true, do: "true", else: "false"
|
||
Map.put(acc, param_key, param_value)
|
||
end)
|
||
end
|
||
|
||
# -------------------------------------------------------------
|
||
# Loading members
|
||
# -------------------------------------------------------------
|
||
|
||
defp load_members(socket) do
|
||
search_query = socket.assigns.query
|
||
|
||
query =
|
||
Mv.Membership.Member
|
||
|> Ash.Query.new()
|
||
|> Ash.Query.select(@overview_fields)
|
||
|
||
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
||
|
||
boolean_custom_fields_map =
|
||
socket.assigns.boolean_custom_fields
|
||
|> Map.new(fn cf -> {to_string(cf.id), cf} end)
|
||
|
||
active_boolean_filter_ids =
|
||
socket.assigns.boolean_custom_field_filters
|
||
|> Map.keys()
|
||
|> Enum.filter(fn id_str ->
|
||
String.length(id_str) <= @max_uuid_length &&
|
||
match?({:ok, _}, Ecto.UUID.cast(id_str)) &&
|
||
Map.has_key?(boolean_custom_fields_map, id_str)
|
||
end)
|
||
|
||
ids_to_load =
|
||
(visible_custom_field_ids ++ active_boolean_filter_ids)
|
||
|> Enum.uniq()
|
||
|
||
query = load_custom_field_values(query, ids_to_load)
|
||
|
||
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
|
||
|
||
query = apply_search_filter(query, search_query)
|
||
|
||
# Use ALL custom fields for sorting (not just show_in_overview subset)
|
||
custom_fields_for_sort = socket.assigns.all_custom_fields
|
||
|
||
{query, sort_after_load} =
|
||
maybe_sort(
|
||
query,
|
||
socket.assigns.sort_field,
|
||
socket.assigns.sort_order,
|
||
custom_fields_for_sort
|
||
)
|
||
|
||
# Errors in handle_params are handled by Phoenix LiveView
|
||
actor = current_actor(socket)
|
||
{time_microseconds, members} = :timer.tc(fn -> Ash.read!(query, actor: actor) end)
|
||
Logger.info("Ash.read! in load_members/1 took #{time_microseconds / 1000} ms")
|
||
|
||
# Custom field values are already filtered at the database level in load_custom_field_values/2
|
||
# No need for in-memory filtering anymore
|
||
|
||
# Apply cycle status filter if set
|
||
members =
|
||
apply_cycle_status_filter(
|
||
members,
|
||
socket.assigns.cycle_status_filter,
|
||
socket.assigns.show_current_cycle
|
||
)
|
||
|
||
# Apply boolean custom field filters if set
|
||
members =
|
||
apply_boolean_custom_field_filters(
|
||
members,
|
||
socket.assigns.boolean_custom_field_filters,
|
||
socket.assigns.all_custom_fields
|
||
)
|
||
|
||
# Sort in memory if needed (custom fields only; computed fields are blocked)
|
||
members =
|
||
if sort_after_load and
|
||
socket.assigns.sort_field not in FieldVisibility.computed_member_fields() do
|
||
sort_members_in_memory(
|
||
members,
|
||
socket.assigns.sort_field,
|
||
socket.assigns.sort_order,
|
||
custom_fields_for_sort
|
||
)
|
||
else
|
||
members
|
||
end
|
||
|
||
assign(socket, :members, members)
|
||
end
|
||
|
||
defp load_custom_field_values(query, []), do: query
|
||
|
||
defp load_custom_field_values(query, custom_field_ids) do
|
||
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
|
||
# -------------------------------------------------------------
|
||
|
||
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
|
||
|
||
defp apply_cycle_status_filter(members, nil, _show_current), do: members
|
||
|
||
defp apply_cycle_status_filter(members, status, show_current)
|
||
when status in [:paid, :unpaid] do
|
||
MembershipFeeStatus.filter_members_by_cycle_status(members, status, show_current)
|
||
end
|
||
|
||
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.
|
||
# Only DB member fields and custom fields; computed fields (e.g. membership_fee_status) are never passed to Ash.
|
||
# Returns {query, sort_after_load} where sort_after_load is true if we need to sort in memory.
|
||
defp maybe_sort(query, nil, _order, _custom_fields), do: {query, false}
|
||
defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false}
|
||
|
||
defp maybe_sort(query, field, order, _custom_fields) do
|
||
computed_atoms = FieldVisibility.computed_member_fields()
|
||
computed_strings = Enum.map(computed_atoms, &Atom.to_string/1)
|
||
|
||
cond do
|
||
# Block computed fields (atom and string variants)
|
||
(is_atom(field) and field in computed_atoms) or
|
||
(is_binary(field) and field in computed_strings) ->
|
||
{query, false}
|
||
|
||
# Custom field sort -> after load
|
||
custom_field_sort?(field) ->
|
||
{query, true}
|
||
|
||
# DB field sort (atom)
|
||
is_atom(field) ->
|
||
{Ash.Query.sort(query, [{field, order}]), false}
|
||
|
||
# DB field sort (string) -> convert only if allowed
|
||
is_binary(field) ->
|
||
case safe_member_field_atom_only(field) do
|
||
nil -> {query, false}
|
||
atom -> {Ash.Query.sort(query, [{atom, order}]), false}
|
||
end
|
||
|
||
true ->
|
||
{query, false}
|
||
end
|
||
end
|
||
|
||
defp valid_sort_field?(field) when is_atom(field) do
|
||
if field in FieldVisibility.computed_member_fields(),
|
||
do: false,
|
||
else: valid_sort_field_db_or_custom?(field)
|
||
end
|
||
|
||
defp valid_sort_field?(field) when is_binary(field) do
|
||
if field in Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) do
|
||
false
|
||
else
|
||
valid_sort_field_db_or_custom?(field)
|
||
end
|
||
end
|
||
|
||
defp valid_sort_field?(_), do: false
|
||
|
||
defp valid_sort_field_db_or_custom?(field) when is_atom(field) do
|
||
non_sortable_fields = [:notes]
|
||
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
|
||
field in valid_fields or custom_field_sort?(field)
|
||
end
|
||
|
||
defp valid_sort_field_db_or_custom?(field) when is_binary(field) do
|
||
custom_field_sort?(field) or
|
||
((atom = safe_member_field_atom_only(field)) != nil and valid_sort_field_db_or_custom?(atom))
|
||
end
|
||
|
||
defp safe_member_field_atom_only(str) do
|
||
allowed = MapSet.new(Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1))
|
||
if MapSet.member?(allowed, str), do: String.to_existing_atom(str), else: nil
|
||
end
|
||
|
||
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
|
||
|
||
defp extract_custom_field_id(field) when is_atom(field) do
|
||
field |> Atom.to_string() |> extract_custom_field_id()
|
||
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
|
||
|
||
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
|
||
|
||
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
|
||
|
||
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
|
||
|
||
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
|
||
|
||
defp sort_members_with_custom_field(members, custom_field, order) do
|
||
{members_with_values, members_without_values} =
|
||
split_members_by_value_presence(members, custom_field)
|
||
|
||
sorted_with_values = sort_members_with_values(members_with_values, custom_field, order)
|
||
sorted_with_values ++ members_without_values
|
||
end
|
||
|
||
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
|
||
|
||
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
|
||
|
||
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)
|
||
|
||
if order == :desc, do: Enum.reverse(sorted), else: sorted
|
||
end
|
||
|
||
defp extract_sort_value(%Ash.Union{value: value, type: type}, _expected_type),
|
||
do: extract_sort_value(value, type)
|
||
|
||
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)
|
||
|
||
defp empty_value?(value, :string) when is_binary(value), do: String.trim(value) == ""
|
||
defp empty_value?(value, :email) when is_binary(value), do: String.trim(value) == ""
|
||
defp empty_value?(_value, _type), do: false
|
||
|
||
defp normalize_sort_value(value, _order), do: value
|
||
|
||
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, ""), do: default
|
||
defp determine_field(default, nil), do: default
|
||
|
||
defp determine_field(default, sf) when is_binary(sf) do
|
||
computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1)
|
||
|
||
if sf in computed_strings,
|
||
do: default,
|
||
else: determine_field_after_computed_check(default, sf)
|
||
end
|
||
|
||
defp determine_field(default, sf) when is_atom(sf) do
|
||
if sf in FieldVisibility.computed_member_fields(),
|
||
do: default,
|
||
else: determine_field_after_computed_check(default, sf)
|
||
end
|
||
|
||
defp determine_field(default, _), do: default
|
||
|
||
defp determine_field_after_computed_check(default, sf) when is_binary(sf) do
|
||
if custom_field_sort?(sf) do
|
||
if valid_sort_field?(sf), do: sf, else: default
|
||
else
|
||
atom = safe_member_field_atom_only(sf)
|
||
if atom != nil and valid_sort_field?(atom), do: atom, else: default
|
||
end
|
||
end
|
||
|
||
defp determine_field_after_computed_check(default, sf) when is_atom(sf) do
|
||
if valid_sort_field?(sf), do: sf, else: default
|
||
end
|
||
|
||
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
|
||
|
||
defp maybe_update_search(socket, %{"query" => query}) when query != "",
|
||
do: assign(socket, :query, query)
|
||
|
||
defp maybe_update_search(socket, _params), do: socket
|
||
|
||
defp maybe_update_cycle_status_filter(socket, %{"cycle_status_filter" => filter_str}) do
|
||
filter = determine_cycle_status_filter(filter_str)
|
||
assign(socket, :cycle_status_filter, filter)
|
||
end
|
||
|
||
defp maybe_update_cycle_status_filter(socket, _params),
|
||
do: assign(socket, :cycle_status_filter, nil)
|
||
|
||
defp determine_cycle_status_filter("paid"), do: :paid
|
||
defp determine_cycle_status_filter("unpaid"), do: :unpaid
|
||
defp determine_cycle_status_filter(_), do: nil
|
||
|
||
defp maybe_update_boolean_filters(socket, params) do
|
||
boolean_custom_fields =
|
||
socket.assigns.all_custom_fields
|
||
|> Enum.filter(&(&1.value_type == :boolean))
|
||
|> Map.new(fn cf -> {to_string(cf.id), cf} end)
|
||
|
||
prefix_length = String.length(@boolean_filter_prefix)
|
||
|
||
{filters, total_processed} =
|
||
params
|
||
|> Enum.filter(fn {key, _value} -> String.starts_with?(key, @boolean_filter_prefix) end)
|
||
|> Enum.reduce_while({%{}, 0}, fn {key, value_str}, {acc, count} ->
|
||
if count >= @max_boolean_filters do
|
||
{:halt, {acc, count}}
|
||
else
|
||
new_acc =
|
||
process_boolean_filter_param(
|
||
key,
|
||
value_str,
|
||
prefix_length,
|
||
boolean_custom_fields,
|
||
acc
|
||
)
|
||
|
||
{:cont, {new_acc, count + 1}}
|
||
end
|
||
end)
|
||
|
||
if total_processed >= @max_boolean_filters do
|
||
Logger.warning(
|
||
"Boolean filter limit reached: processed #{total_processed} parameters, accepted #{map_size(filters)} valid filters (max: #{@max_boolean_filters})"
|
||
)
|
||
end
|
||
|
||
assign(socket, :boolean_custom_field_filters, filters)
|
||
end
|
||
|
||
defp process_boolean_filter_param(key, value_str, prefix_length, boolean_custom_fields, acc) do
|
||
custom_field_id_str = String.slice(key, prefix_length, String.length(key) - prefix_length)
|
||
|
||
if String.length(custom_field_id_str) > @max_uuid_length do
|
||
acc
|
||
else
|
||
validate_and_add_boolean_filter(custom_field_id_str, value_str, boolean_custom_fields, acc)
|
||
end
|
||
end
|
||
|
||
defp validate_and_add_boolean_filter(custom_field_id_str, value_str, boolean_custom_fields, acc) do
|
||
case Ecto.UUID.cast(custom_field_id_str) do
|
||
{:ok, _custom_field_id} ->
|
||
add_boolean_filter_if_valid(custom_field_id_str, value_str, boolean_custom_fields, acc)
|
||
|
||
:error ->
|
||
acc
|
||
end
|
||
end
|
||
|
||
defp add_boolean_filter_if_valid(custom_field_id_str, value_str, boolean_custom_fields, acc) do
|
||
if Map.has_key?(boolean_custom_fields, custom_field_id_str) do
|
||
case determine_boolean_filter(value_str) do
|
||
nil -> acc
|
||
filter_value -> Map.put(acc, custom_field_id_str, filter_value)
|
||
end
|
||
else
|
||
acc
|
||
end
|
||
end
|
||
|
||
defp determine_boolean_filter("true"), do: true
|
||
defp determine_boolean_filter("false"), do: false
|
||
defp determine_boolean_filter(_), do: nil
|
||
|
||
defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}),
|
||
do: assign(socket, :show_current_cycle, true)
|
||
|
||
defp maybe_update_show_current_cycle(socket, _params), do: socket
|
||
|
||
# -------------------------------------------------------------
|
||
# Custom Field Value Helpers
|
||
# -------------------------------------------------------------
|
||
|
||
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
|
||
(match?(%{custom_field: %{id: _}}, cfv) && cfv.custom_field.id == custom_field.id)
|
||
end)
|
||
|
||
_ ->
|
||
nil
|
||
end
|
||
end
|
||
|
||
def get_boolean_custom_field_value(member, custom_field) do
|
||
case get_custom_field_value(member, custom_field) do
|
||
nil -> nil
|
||
cfv -> extract_boolean_value(cfv.value)
|
||
end
|
||
end
|
||
|
||
defp extract_boolean_value(%Ash.Union{value: value, type: :boolean}),
|
||
do: extract_boolean_value(value)
|
||
|
||
defp extract_boolean_value(value) when is_map(value) do
|
||
type = Map.get(value, "type") || Map.get(value, "_union_type")
|
||
val = Map.get(value, "value") || Map.get(value, "_union_value")
|
||
|
||
if type == "boolean" or type == :boolean do
|
||
extract_boolean_value(val)
|
||
else
|
||
nil
|
||
end
|
||
end
|
||
|
||
defp extract_boolean_value(value) when is_boolean(value), do: value
|
||
defp extract_boolean_value(nil), do: nil
|
||
defp extract_boolean_value(_), do: nil
|
||
|
||
def apply_boolean_custom_field_filters(members, filters, _all_custom_fields)
|
||
when map_size(filters) == 0 do
|
||
members
|
||
end
|
||
|
||
def apply_boolean_custom_field_filters(members, filters, all_custom_fields) do
|
||
valid_custom_field_ids =
|
||
all_custom_fields
|
||
|> Enum.filter(&(&1.value_type == :boolean))
|
||
|> MapSet.new(fn cf -> to_string(cf.id) end)
|
||
|
||
valid_filters =
|
||
Enum.filter(filters, fn {custom_field_id_str, _value} ->
|
||
MapSet.member?(valid_custom_field_ids, custom_field_id_str)
|
||
end)
|
||
|> Enum.into(%{})
|
||
|
||
if map_size(valid_filters) == 0 do
|
||
members
|
||
else
|
||
Enum.filter(members, fn member -> matches_all_filters?(member, valid_filters) end)
|
||
end
|
||
end
|
||
|
||
defp matches_all_filters?(member, filters) do
|
||
Enum.all?(filters, fn {custom_field_id_str, filter_value} ->
|
||
matches_filter?(member, custom_field_id_str, filter_value)
|
||
end)
|
||
end
|
||
|
||
defp matches_filter?(member, custom_field_id_str, filter_value) do
|
||
case find_custom_field_value_by_id(member, custom_field_id_str) do
|
||
nil -> false
|
||
cfv -> extract_boolean_value(cfv.value) == filter_value
|
||
end
|
||
end
|
||
|
||
defp find_custom_field_value_by_id(member, custom_field_id_str) do
|
||
case member.custom_field_values do
|
||
nil ->
|
||
nil
|
||
|
||
values when is_list(values) ->
|
||
Enum.find(values, fn cfv ->
|
||
to_string(cfv.custom_field_id) == custom_field_id_str or
|
||
(match?(%{custom_field: %{id: _}}, cfv) &&
|
||
to_string(cfv.custom_field.id) == custom_field_id_str)
|
||
end)
|
||
|
||
_ ->
|
||
nil
|
||
end
|
||
end
|
||
|
||
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
|
||
|
||
def checkbox_column_click(member), do: JS.push("select_member", value: %{id: member.id})
|
||
|
||
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
|
||
|
||
def format_date(date), do: DateFormatter.format_date(date)
|
||
|
||
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(", ")
|
||
|> URI.encode_www_form()
|
||
else
|
||
""
|
||
end
|
||
|
||
socket
|
||
|> assign(:selected_count, selected_count)
|
||
|> assign(:any_selected?, any_selected?)
|
||
|> assign(:mailto_bcc, mailto_bcc)
|
||
|> assign_export_payload()
|
||
end
|
||
|
||
defp assign_export_payload(socket) do
|
||
payload = build_export_payload(socket)
|
||
assign(socket, :export_payload_json, Jason.encode!(payload))
|
||
end
|
||
|
||
defp build_export_payload(socket) do
|
||
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
||
|
||
member_fields_db = socket.assigns[:member_fields_visible_db] || []
|
||
member_fields_computed = socket.assigns[:member_fields_visible_computed] || []
|
||
|
||
# Order DB member fields exactly like the table/constants
|
||
ordered_member_fields_db =
|
||
Mv.Constants.member_fields()
|
||
|> Enum.filter(&(&1 in member_fields_db))
|
||
|
||
# Order computed fields in canonical order
|
||
ordered_computed_fields =
|
||
FieldVisibility.computed_member_fields()
|
||
|> Enum.filter(&(&1 in member_fields_computed))
|
||
|
||
# Order custom fields like the table (same as dynamic_cols / all_custom_fields order)
|
||
ordered_custom_field_ids =
|
||
socket.assigns.all_custom_fields
|
||
|> Enum.map(&to_string(&1.id))
|
||
|> Enum.filter(&(&1 in visible_custom_field_ids))
|
||
|
||
%{
|
||
selected_ids: socket.assigns.selected_members |> MapSet.to_list(),
|
||
member_fields: Enum.map(ordered_member_fields_db, &Atom.to_string/1),
|
||
computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1),
|
||
custom_field_ids: ordered_custom_field_ids,
|
||
query: socket.assigns[:query] || nil,
|
||
sort_field: export_sort_field(socket.assigns[:sort_field]),
|
||
sort_order: export_sort_order(socket.assigns[:sort_order]),
|
||
show_current_cycle: socket.assigns[:show_current_cycle] || false,
|
||
cycle_status_filter: export_cycle_status_filter(socket.assigns[:cycle_status_filter]),
|
||
boolean_filters: socket.assigns[:boolean_custom_field_filters] || %{}
|
||
}
|
||
end
|
||
|
||
defp export_cycle_status_filter(nil), do: nil
|
||
defp export_cycle_status_filter(:paid), do: "paid"
|
||
defp export_cycle_status_filter(:unpaid), do: "unpaid"
|
||
defp export_cycle_status_filter(_), do: nil
|
||
|
||
defp export_sort_field(nil), do: nil
|
||
defp export_sort_field(f) when is_atom(f), do: Atom.to_string(f)
|
||
defp export_sort_field(f) when is_binary(f), do: f
|
||
|
||
defp export_sort_order(nil), do: nil
|
||
defp export_sort_order(:asc), do: "asc"
|
||
defp export_sort_order(:desc), do: "desc"
|
||
defp export_sort_order(o) when is_binary(o), do: o
|
||
|
||
# -------------------------------------------------------------
|
||
# Internal utility: dedupe dropdown fields defensively
|
||
# -------------------------------------------------------------
|
||
|
||
defp dedupe_available_fields(fields) when is_list(fields) do
|
||
Enum.uniq_by(fields, fn item ->
|
||
cond do
|
||
is_map(item) ->
|
||
Map.get(item, :key) || Map.get(item, :id) || Map.get(item, :field) || item
|
||
|
||
is_tuple(item) and tuple_size(item) >= 1 ->
|
||
elem(item, 0)
|
||
|
||
true ->
|
||
item
|
||
end
|
||
end)
|
||
end
|
||
|
||
defp dedupe_available_fields(other), do: other
|
||
end
|