2145 lines
73 KiB
Elixir
2145 lines
73 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
|
|
- `select_member` - Toggle individual member selection
|
|
- `select_all` - Toggle selection of all visible members
|
|
- `copy_emails` - Copy email addresses of the selected members, or of all/filtered members when nothing is selected
|
|
|
|
## Implementation Notes
|
|
- Search uses PostgreSQL full-text search (plainto_tsquery)
|
|
- Sort state is synced with URL for bookmarkability
|
|
- Components communicate via `handle_info` for decoupling
|
|
"""
|
|
use MvWeb, :live_view
|
|
|
|
import Ash.Expr
|
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
|
|
|
alias Mv.Membership
|
|
alias Mv.MembershipFees
|
|
alias Mv.MembershipFees.MembershipFeeType
|
|
alias MvWeb.Helpers.DateFormatter
|
|
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
|
|
alias MvWeb.MemberLive.Index.DateFilter
|
|
alias MvWeb.MemberLive.Index.FieldSelection
|
|
alias MvWeb.MemberLive.Index.FieldVisibility
|
|
alias MvWeb.MemberLive.Index.FilterParams
|
|
alias MvWeb.MemberLive.Index.Formatter
|
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
|
alias MvWeb.MemberLive.Index.OverviewQuery
|
|
alias MvWeb.MemberLive.Index.ViewSettings
|
|
|
|
require Ash.Query
|
|
require Logger
|
|
|
|
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
|
@boolean_filter_prefix Mv.Constants.boolean_filter_prefix()
|
|
@group_filter_prefix Mv.Constants.group_filter_prefix()
|
|
@fee_type_filter_prefix Mv.Constants.fee_type_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)
|
|
|
|
all_custom_fields =
|
|
Mv.Membership.CustomField
|
|
|> Ash.Query.sort(name: :asc)
|
|
|> Ash.read!(actor: actor)
|
|
|
|
custom_fields_visible =
|
|
all_custom_fields
|
|
|> Enum.filter(& &1.show_in_overview)
|
|
|
|
# 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)
|
|
|
|
# Date-typed custom fields surface in the new "Custom date fields" filter
|
|
# section and are needed by DateFilter.from_params/2 to validate UUIDs.
|
|
date_custom_fields =
|
|
all_custom_fields
|
|
|> Enum.filter(&(&1.value_type == :date))
|
|
|> Enum.sort_by(& &1.name, :asc)
|
|
|
|
# Load groups for filter dropdown (sorted by name)
|
|
groups =
|
|
Mv.Membership.Group
|
|
|> Ash.Query.sort(name: :asc)
|
|
|> Ash.read!(actor: actor)
|
|
|
|
# Load membership fee types for filter dropdown (sorted by name)
|
|
fee_types =
|
|
MembershipFeeType
|
|
|> Ash.Query.sort(name: :asc)
|
|
|> Ash.read!(domain: MembershipFees, actor: actor)
|
|
|
|
# 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
|
|
|
|
# Ensure nested module is loaded (can be missing after code reload in dev if load order changes)
|
|
Code.ensure_loaded!(FieldSelection)
|
|
|
|
# Load user field selection from session
|
|
session_selection = FieldSelection.get_from_session(session)
|
|
|
|
# Resolve the per-browser view settings first: the columns the manager offers
|
|
# (composite vs. constituent Name/Address columns) follow these settings (§7b).
|
|
view_settings = ViewSettings.resolve(session, connect_conn(socket), connect_params(socket))
|
|
compact_member = view_settings.compact_member
|
|
member_include_email = view_settings.member_include_email
|
|
compact_address = view_settings.compact_address
|
|
|
|
# The dropdown offers exactly the columns applicable to the current view
|
|
# settings, so composite and constituent columns are never both listed.
|
|
all_available_fields =
|
|
FieldVisibility.get_offered_fields(
|
|
all_custom_fields,
|
|
compact_member,
|
|
member_include_email,
|
|
compact_address
|
|
)
|
|
|
|
initial_selection =
|
|
FieldVisibility.merge_with_global_settings(
|
|
session_selection,
|
|
settings,
|
|
all_custom_fields,
|
|
compact_member: compact_member,
|
|
member_include_email: member_include_email,
|
|
compact_address: compact_address
|
|
)
|
|
|
|
socket =
|
|
socket
|
|
|> Layouts.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(:group_filters, %{})
|
|
|> assign(:groups, groups)
|
|
|> assign(:fee_type_filters, %{})
|
|
|> assign(:fee_types, fee_types)
|
|
|> assign(:boolean_custom_field_filters, %{})
|
|
|> assign(:selected_members, MapSet.new())
|
|
|> assign(:selected_member_id, nil)
|
|
|> 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(:date_custom_fields, date_custom_fields)
|
|
|> assign(:date_filters, DateFilter.default())
|
|
|> assign(:all_available_fields, all_available_fields)
|
|
|> assign(:user_field_selection, initial_selection)
|
|
|> assign(:fields_in_url?, false)
|
|
|> 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(:page, nil)
|
|
|> assign(:after_cursor, nil)
|
|
|> assign(:more?, false)
|
|
|> assign(:total_count, 0)
|
|
|> assign(:loading?, false)
|
|
# The bulk-actions dropdown's open state lives here so the mailto recipient
|
|
# list can be fetched lazily on open (never on a mere selection change).
|
|
|> assign(:bulk_actions_open, false)
|
|
|> assign(:mailto_bcc, "")
|
|
|> assign(:recipient_count, 0)
|
|
|> assign(:mailto_disabled?, false)
|
|
|> assign_view_settings(view_settings)
|
|
|> stream_configure(:members, dom_id: &"row-#{&1.id}")
|
|
|> stream(:members, [])
|
|
|> assign_export_payload()
|
|
|
|
{:ok, socket}
|
|
end
|
|
|
|
# Number of members fetched per keyset page (matches the :overview action's
|
|
# default_limit). Mount loads one page; further pages arrive via infinite
|
|
# scroll (phx-viewport-bottom) so the socket never holds the full table.
|
|
@page_limit 50
|
|
|
|
# -----------------------------------------------------------------
|
|
# Handle Events
|
|
# -----------------------------------------------------------------
|
|
|
|
@doc """
|
|
Handles member-related UI events.
|
|
|
|
## Supported events:
|
|
- `"select_member"` - Toggles individual member selection
|
|
- `"select_all"` - Toggles selection of all visible members
|
|
- `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
|
|
"""
|
|
@impl true
|
|
def handle_event("select_row_and_navigate", %{"id" => id}, socket) do
|
|
# Navigate to member show. Back button on show page uses ?highlight=id so returning to index shows row as selected.
|
|
{:noreply, push_navigate(socket, to: ~p"/members/#{id}")}
|
|
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)
|
|
|> restream_member(id)
|
|
|> update_selection_assigns()}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("select_all", _params, socket) do
|
|
# Select-all spans the whole filtered set, not just the loaded page (§1.17):
|
|
# re-query every matching id. Toggling off when all are already selected.
|
|
all_ids = all_filtered_member_ids(socket)
|
|
|
|
selected =
|
|
if MapSet.equal?(socket.assigns.selected_members, all_ids) do
|
|
MapSet.new()
|
|
else
|
|
all_ids
|
|
end
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:selected_members, selected)
|
|
|> restream_all_members()
|
|
|> update_selection_assigns()}
|
|
end
|
|
|
|
# The bulk-actions dropdown routes its open/close here (un-targeted) so the
|
|
# mailto recipient list is fetched only when the menu actually opens, not on
|
|
# every selection or filter change.
|
|
@impl true
|
|
def handle_event("toggle_dropdown", _params, socket) do
|
|
open? = !socket.assigns.bulk_actions_open
|
|
|
|
socket = assign(socket, :bulk_actions_open, open?)
|
|
socket = if open?, do: assign_mailto_recipients(socket), else: socket
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("close_dropdown", _params, socket) do
|
|
{:noreply, assign(socket, :bulk_actions_open, false)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("load_more", _params, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> load_more()
|
|
|> 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()
|
|
|> scroll_list_to_top()
|
|
|> update_selection_assigns()
|
|
|
|
query_params =
|
|
build_query_params(opts_for_query_params(socket, %{show_current_cycle: new_show_current}))
|
|
|> maybe_add_field_selection(
|
|
socket.assigns[:user_field_selection],
|
|
socket.assigns[:fields_in_url?] || false
|
|
)
|
|
|
|
new_path = ~p"/members?#{query_params}"
|
|
|
|
{:noreply, push_reload(socket, new_path)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("copy_emails", _params, socket) do
|
|
# Recipients follow the current scope, re-queried from the DB so a no-selection
|
|
# (all/filtered) copy or a select-all copy spans the whole filtered set rather
|
|
# than only the loaded page (§1.17). Members without an email are excluded;
|
|
# the empty-recipient feedback below is preserved.
|
|
formatted_emails = scope_emails(socket)
|
|
email_count = length(formatted_emails)
|
|
|
|
if email_count == 0 do
|
|
{:noreply, put_flash(socket, :error, gettext("No email addresses found"))}
|
|
else
|
|
# 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
|
|
|
|
@impl true
|
|
def handle_event("sort", %{"field" => 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()
|
|
|> scroll_list_to_top()
|
|
|> update_selection_assigns()
|
|
|
|
# URL sync - push_patch happens synchronously in the event handler
|
|
query_params =
|
|
build_query_params(
|
|
opts_for_query_params(socket, %{
|
|
sort_field: export_sort_field(socket.assigns.sort_field),
|
|
sort_order: export_sort_order(socket.assigns.sort_order)
|
|
})
|
|
)
|
|
|> maybe_add_field_selection(
|
|
socket.assigns[:user_field_selection],
|
|
socket.assigns[:fields_in_url?] || false
|
|
)
|
|
|
|
{:noreply, push_reload(socket, ~p"/members?#{query_params}")}
|
|
end
|
|
|
|
# -----------------------------------------------------------------
|
|
# Handle Infos from Child Components
|
|
# -----------------------------------------------------------------
|
|
|
|
@doc """
|
|
Handles messages from child components.
|
|
|
|
## Supported messages:
|
|
- `{: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({:view_setting_toggled, key}, socket) do
|
|
{:noreply, toggle_view_setting(socket, key)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:search_changed, q}, socket) do
|
|
socket =
|
|
socket
|
|
|> assign(:query, q)
|
|
|> load_members()
|
|
|> scroll_list_to_top()
|
|
|> update_selection_assigns()
|
|
|
|
query_params =
|
|
build_query_params(opts_for_query_params(socket, %{query: q}))
|
|
|> maybe_add_field_selection(
|
|
socket.assigns[:user_field_selection],
|
|
socket.assigns[:fields_in_url?] || false
|
|
)
|
|
|
|
new_path = ~p"/members?#{query_params}"
|
|
|
|
{:noreply, push_reload(socket, new_path)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:payment_filter_changed, filter}, socket) do
|
|
socket =
|
|
socket
|
|
|> assign(:cycle_status_filter, filter)
|
|
|> load_members()
|
|
|> scroll_list_to_top()
|
|
|> update_selection_assigns()
|
|
|
|
query_params =
|
|
build_query_params(opts_for_query_params(socket, %{cycle_status_filter: filter}))
|
|
|> maybe_add_field_selection(
|
|
socket.assigns[:user_field_selection],
|
|
socket.assigns[:fields_in_url?] || false
|
|
)
|
|
|
|
new_path = ~p"/members?#{query_params}"
|
|
{:noreply, push_reload(socket, new_path)}
|
|
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()
|
|
|> scroll_list_to_top()
|
|
|> update_selection_assigns()
|
|
|
|
query_params =
|
|
build_query_params(opts_for_query_params(socket, %{boolean_filters: updated_filters}))
|
|
|> maybe_add_field_selection(
|
|
socket.assigns[:user_field_selection],
|
|
socket.assigns[:fields_in_url?] || false
|
|
)
|
|
|
|
new_path = ~p"/members?#{query_params}"
|
|
{:noreply, push_reload(socket, new_path)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:group_filter_changed, group_id_str, filter_value}, socket) do
|
|
normalized_id = normalize_uuid_string(group_id_str) || group_id_str
|
|
|
|
group_filters =
|
|
if filter_value == nil do
|
|
Map.delete(socket.assigns.group_filters, normalized_id)
|
|
else
|
|
Map.put(socket.assigns.group_filters, normalized_id, filter_value)
|
|
end
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:group_filters, group_filters)
|
|
|> load_members()
|
|
|> scroll_list_to_top()
|
|
|> update_selection_assigns()
|
|
|
|
query_params =
|
|
build_query_params(opts_for_query_params(socket, %{group_filters: group_filters}))
|
|
|> maybe_add_field_selection(
|
|
socket.assigns[:user_field_selection],
|
|
socket.assigns[:fields_in_url?] || false
|
|
)
|
|
|
|
new_path = ~p"/members?#{query_params}"
|
|
{:noreply, push_reload(socket, new_path)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:fee_type_filter_changed, fee_type_id_str, filter_value}, socket) do
|
|
normalized_id = normalize_uuid_string(fee_type_id_str) || fee_type_id_str
|
|
|
|
fee_type_filters =
|
|
if filter_value == nil do
|
|
Map.delete(socket.assigns.fee_type_filters, normalized_id)
|
|
else
|
|
Map.put(socket.assigns.fee_type_filters, normalized_id, filter_value)
|
|
end
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:fee_type_filters, fee_type_filters)
|
|
|> load_members()
|
|
|> scroll_list_to_top()
|
|
|> update_selection_assigns()
|
|
|
|
query_params =
|
|
build_query_params(opts_for_query_params(socket, %{fee_type_filters: fee_type_filters}))
|
|
|> maybe_add_field_selection(
|
|
socket.assigns[:user_field_selection],
|
|
socket.assigns[:fields_in_url?] || false
|
|
)
|
|
|
|
new_path = ~p"/members?#{query_params}"
|
|
{:noreply, push_reload(socket, new_path)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:date_filters_changed, new_date_filters}, socket) do
|
|
socket =
|
|
socket
|
|
|> assign(:date_filters, new_date_filters)
|
|
|> load_members()
|
|
|> scroll_list_to_top()
|
|
|> update_selection_assigns()
|
|
|
|
query_params =
|
|
build_query_params(opts_for_query_params(socket, %{date_filters: new_date_filters}))
|
|
|> maybe_add_field_selection(
|
|
socket.assigns[:user_field_selection],
|
|
socket.assigns[:fields_in_url?] || false
|
|
)
|
|
|
|
new_path = ~p"/members?#{query_params}"
|
|
{:noreply, push_reload(socket, new_path)}
|
|
end
|
|
|
|
# Backward compatibility: tuple form delegates to map form
|
|
def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
|
|
handle_info(
|
|
{:reset_all_filters,
|
|
%{
|
|
cycle_status_filter: cycle_status_filter,
|
|
boolean_filters: boolean_filters,
|
|
group_filters: %{},
|
|
fee_type_filters: %{}
|
|
}},
|
|
socket
|
|
)
|
|
end
|
|
|
|
def handle_info(
|
|
{:reset_all_filters, cycle_status_filter, boolean_filters, group_filters},
|
|
socket
|
|
) do
|
|
handle_info(
|
|
{:reset_all_filters,
|
|
%{
|
|
cycle_status_filter: cycle_status_filter,
|
|
boolean_filters: boolean_filters,
|
|
group_filters: group_filters,
|
|
fee_type_filters: %{}
|
|
}},
|
|
socket
|
|
)
|
|
end
|
|
|
|
def handle_info(
|
|
{:reset_all_filters, cycle_status_filter, boolean_filters, group_filters,
|
|
fee_type_filters},
|
|
socket
|
|
) do
|
|
handle_info(
|
|
{:reset_all_filters,
|
|
%{
|
|
cycle_status_filter: cycle_status_filter,
|
|
boolean_filters: boolean_filters,
|
|
group_filters: group_filters,
|
|
fee_type_filters: fee_type_filters
|
|
}},
|
|
socket
|
|
)
|
|
end
|
|
|
|
def handle_info({:reset_all_filters, %{} = opts}, socket) do
|
|
socket =
|
|
socket
|
|
|> assign(:cycle_status_filter, Map.get(opts, :cycle_status_filter))
|
|
|> assign(:group_filters, Map.get(opts, :group_filters, %{}))
|
|
|> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{}))
|
|
|> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{}))
|
|
|> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default()))
|
|
|> load_members()
|
|
|> scroll_list_to_top()
|
|
|> update_selection_assigns()
|
|
|
|
query_params =
|
|
build_query_params(opts_for_query_params(socket))
|
|
|> maybe_add_field_selection(
|
|
socket.assigns[:user_field_selection],
|
|
socket.assigns[:fields_in_url?] || false
|
|
)
|
|
|
|
new_path = ~p"/members?#{query_params}"
|
|
{:noreply, push_reload(socket, new_path)}
|
|
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 =
|
|
socket
|
|
|> assign_field_visibility(new_selection)
|
|
|> load_members()
|
|
|> prepare_dynamic_cols()
|
|
|> update_selection_assigns()
|
|
|> push_field_selection_url()
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:fields_reset}, socket) do
|
|
# Reset restores the curated default column set (§1.6): recompute the default
|
|
# visibility from the global settings and re-apply it as the selection.
|
|
{cm, ie, ca} = view_flags(socket)
|
|
|
|
default_selection =
|
|
FieldVisibility.merge_with_global_settings(
|
|
%{},
|
|
socket.assigns.settings,
|
|
socket.assigns.all_custom_fields,
|
|
compact_member: cm,
|
|
member_include_email: ie,
|
|
compact_address: ca
|
|
)
|
|
|
|
handle_info({:fields_selected, default_selection}, socket)
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:fields_selected, selection}, socket) do
|
|
socket =
|
|
socket
|
|
|> assign_field_visibility(selection)
|
|
|> 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
|
|
url = url || request_url_from_socket(socket)
|
|
params = merge_fields_param_from_uri(params, url)
|
|
prev_sig = build_signature(socket)
|
|
|
|
fields_in_url? =
|
|
case Map.get(params, "fields") do
|
|
v when is_binary(v) and v != "" -> true
|
|
_ -> false
|
|
end
|
|
|
|
url_selection = FieldSelection.parse_from_url(params)
|
|
final_selection = compute_final_field_selection(fields_in_url?, url_selection, socket)
|
|
|
|
socket =
|
|
socket
|
|
|> maybe_update_search(params)
|
|
|> maybe_update_sort(params)
|
|
|> maybe_update_cycle_status_filter(params)
|
|
|> maybe_update_group_filters(params)
|
|
|> maybe_update_fee_type_filters(params)
|
|
|> maybe_update_boolean_filters(params)
|
|
|> maybe_update_date_filters(params)
|
|
|> maybe_update_show_current_cycle(params)
|
|
|> assign(:fields_in_url?, fields_in_url?)
|
|
|> assign(:query, params["query"])
|
|
|> assign_visibility_derivations(final_selection)
|
|
|> assign(:selected_member_id, parse_highlight_param(params["highlight"]))
|
|
|
|
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
|
|
|
|
# The (re)load is complete: clear the busy flag set by `push_reload/2` (§1.10).
|
|
socket = assign(socket, :loading?, false)
|
|
|
|
# Update sort components after rendering
|
|
socket =
|
|
if socket.assigns[:sort_needs_update] do
|
|
old_field = socket.assigns[:previous_sort_field] || socket.assigns.sort_field
|
|
|
|
socket
|
|
|> update_sort_components(old_field, socket.assigns.sort_field, socket.assigns.sort_order)
|
|
|> assign(:sort_needs_update, false)
|
|
|> assign(:previous_sort_field, nil)
|
|
else
|
|
socket
|
|
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[:group_filters],
|
|
socket.assigns[:fee_type_filters],
|
|
socket.assigns.show_current_cycle,
|
|
socket.assigns.boolean_custom_field_filters,
|
|
socket.assigns.user_field_selection,
|
|
socket.assigns[:visible_custom_field_ids] || [],
|
|
socket.assigns[:date_filters]
|
|
}
|
|
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 ->
|
|
if custom_field.value_type == :boolean do
|
|
extract_boolean_value(cfv.value)
|
|
else
|
|
Formatter.format_custom_field_value(cfv.value, custom_field)
|
|
end
|
|
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
|
|
String.to_existing_atom("sort_#{field}")
|
|
rescue
|
|
ArgumentError -> :"sort_#{field}"
|
|
end
|
|
|
|
defp to_sort_id(field) when is_atom(field), do: :"sort_#{field}"
|
|
|
|
# Only keep `fields` in the URL when it was already present (bookmark/share),
|
|
# OR when we intentionally push it via push_field_selection_url/1.
|
|
defp maybe_add_field_selection(params, selection, true) when is_map(selection) do
|
|
fields_param = FieldSelection.to_url_param(selection)
|
|
|
|
if fields_param == "" do
|
|
Map.delete(params, "fields")
|
|
else
|
|
Map.put(params, "fields", fields_param)
|
|
end
|
|
end
|
|
|
|
defp maybe_add_field_selection(params, _selection, _include?), do: params
|
|
|
|
defp push_field_selection_url(socket) do
|
|
query_params =
|
|
build_query_params(opts_for_query_params(socket))
|
|
|> maybe_add_field_selection(socket.assigns[:user_field_selection], true)
|
|
|
|
new_path = ~p"/members?#{query_params}"
|
|
push_reload(socket, new_path)
|
|
end
|
|
|
|
# The current composite view flags; the offered column set is scoped to these.
|
|
defp view_flags(socket) do
|
|
{socket.assigns.compact_member, socket.assigns.member_include_email,
|
|
socket.assigns.compact_address}
|
|
end
|
|
|
|
# Merges a raw selection with the global settings scoped to the current view
|
|
# settings, then assigns it together with the visibility-derived assigns.
|
|
defp assign_field_visibility(socket, selection) do
|
|
{cm, ie, ca} = view_flags(socket)
|
|
|
|
final =
|
|
FieldVisibility.merge_with_global_settings(
|
|
selection,
|
|
socket.assigns.settings,
|
|
socket.assigns.all_custom_fields,
|
|
compact_member: cm,
|
|
member_include_email: ie,
|
|
compact_address: ca
|
|
)
|
|
|
|
assign_visibility_derivations(socket, final)
|
|
end
|
|
|
|
# Assigns user_field_selection and every visibility-derived assign the table and
|
|
# export payload read from an already merged (offered-scoped) selection.
|
|
defp assign_visibility_derivations(socket, final_selection) do
|
|
visible_member_fields =
|
|
final_selection
|
|
|> FieldVisibility.get_visible_member_fields()
|
|
|> Enum.uniq()
|
|
|
|
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
|
|
|
|
socket
|
|
|> assign(:user_field_selection, final_selection)
|
|
|> assign(:member_fields_visible, visible_member_fields)
|
|
|> assign(
|
|
:member_fields_visible_db,
|
|
FieldVisibility.get_visible_member_fields_db(final_selection)
|
|
)
|
|
|> assign(
|
|
:member_fields_visible_computed,
|
|
FieldVisibility.get_visible_member_fields_computed(final_selection)
|
|
)
|
|
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
|
end
|
|
|
|
defp build_query_params(opts) when is_map(opts) do
|
|
base_params = build_base_params(opts.query, opts.sort_field, opts.sort_order)
|
|
base_params = add_cycle_status_filter(base_params, opts.cycle_status_filter)
|
|
base_params = add_group_filters(base_params, opts.group_filters || %{})
|
|
base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{})
|
|
base_params = add_show_current_cycle(base_params, opts.show_current_cycle)
|
|
base_params = add_boolean_filters(base_params, opts.boolean_filters || %{})
|
|
add_date_filters(base_params, opts.date_filters)
|
|
end
|
|
|
|
defp add_date_filters(params, date_filters) do
|
|
Map.merge(params, DateFilter.to_params(date_filters))
|
|
end
|
|
|
|
defp opts_for_query_params(socket, overrides \\ %{}) do
|
|
%{
|
|
query: socket.assigns.query,
|
|
sort_field: socket.assigns.sort_field,
|
|
sort_order: socket.assigns.sort_order,
|
|
cycle_status_filter: socket.assigns.cycle_status_filter,
|
|
group_filters: socket.assigns[:group_filters] || %{},
|
|
show_current_cycle: socket.assigns.show_current_cycle,
|
|
boolean_filters: socket.assigns.boolean_custom_field_filters || %{},
|
|
fee_type_filters: socket.assigns[:fee_type_filters] || %{},
|
|
date_filters: socket.assigns.date_filters
|
|
}
|
|
|> Map.merge(overrides)
|
|
end
|
|
|
|
defp add_fee_type_filters(params, fee_type_filters) do
|
|
Enum.reduce(fee_type_filters, params, fn {fee_type_id_str, value}, acc ->
|
|
param_value = if value == :in, do: "in", else: "not_in"
|
|
Map.put(acc, "#{@fee_type_filter_prefix}#{fee_type_id_str}", param_value)
|
|
end)
|
|
end
|
|
|
|
defp compute_final_field_selection(true, url_selection, socket) do
|
|
{cm, ie, ca} = view_flags(socket)
|
|
|
|
only_url =
|
|
FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields,
|
|
compact_member: cm,
|
|
member_include_email: ie,
|
|
compact_address: ca
|
|
)
|
|
|
|
visible_members = FieldVisibility.get_visible_member_fields(only_url)
|
|
visible_custom = FieldVisibility.get_visible_custom_fields(only_url)
|
|
|
|
if visible_members == [] and visible_custom == [] do
|
|
# URL had only invalid field names; fall back to session + global.
|
|
compute_final_field_selection(false, url_selection, socket)
|
|
else
|
|
only_url
|
|
end
|
|
end
|
|
|
|
defp compute_final_field_selection(false, url_selection, socket) do
|
|
{cm, ie, ca} = view_flags(socket)
|
|
|
|
merged =
|
|
FieldSelection.merge_sources(
|
|
url_selection,
|
|
socket.assigns.user_field_selection,
|
|
%{}
|
|
)
|
|
|
|
FieldVisibility.merge_with_global_settings(
|
|
merged,
|
|
socket.assigns.settings,
|
|
socket.assigns.all_custom_fields,
|
|
compact_member: cm,
|
|
member_include_email: ie,
|
|
compact_address: ca
|
|
)
|
|
end
|
|
|
|
# On full page load conn.params has no query string; read "fields" from URI so column visibility is restored.
|
|
defp request_url_from_socket(socket) do
|
|
case socket.private[:connect_info] do
|
|
%Plug.Conn{} = conn -> Plug.Conn.request_url(conn)
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
# The connect-info Plug.Conn (present on the initial dead render), used to read
|
|
# per-browser cookies such as the persisted view settings.
|
|
defp connect_conn(socket) do
|
|
case socket.private[:connect_info] do
|
|
%Plug.Conn{} = conn -> conn
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
# The LiveView connect params (present only on the connected mount). The client
|
|
# echoes the persisted view-settings cookie here because the connect-info map
|
|
# of a live socket does not expose cookies.
|
|
defp connect_params(socket) do
|
|
if connected?(socket), do: get_connect_params(socket), else: nil
|
|
end
|
|
|
|
# Assigns the full view-settings map plus the derived per-setting assigns the
|
|
# template renders from.
|
|
defp assign_view_settings(socket, settings) do
|
|
socket
|
|
|> assign(:view_settings, settings)
|
|
|> assign(:density, settings.density)
|
|
|> assign(:compact_member, settings.compact_member)
|
|
|> assign(:member_include_email, settings.member_include_email)
|
|
|> assign(:compact_address, settings.compact_address)
|
|
end
|
|
|
|
# Updates a single view setting, re-derives the template assigns and persists
|
|
# the whole settings map per browser (client writes the cookie; the connected
|
|
# mount reads it back via connect params).
|
|
defp update_view_setting(socket, key, value) do
|
|
settings = Map.put(socket.assigns.view_settings, key, value)
|
|
|
|
socket
|
|
|> assign_view_settings(settings)
|
|
|> push_event("store-view-settings", %{view_settings: ViewSettings.to_json(settings)})
|
|
end
|
|
|
|
# Flips the density setting. This only re-renders the table wrapper attribute
|
|
# (row spacing token), so the streamed rows do not need to be re-rendered.
|
|
defp toggle_view_setting(socket, :density) do
|
|
new = if socket.assigns.density == :compact, do: :comfortable, else: :compact
|
|
update_view_setting(socket, :density, new)
|
|
end
|
|
|
|
# Flips the "include email" sub-toggle. Only meaningful while the composite
|
|
# Member cell is on: it moves the email between an in-cell line (on) and a
|
|
# separate E-Mail column (off), so besides re-rendering the rows it recomputes
|
|
# which columns the manager offers and their visibility.
|
|
defp toggle_view_setting(socket, :member_include_email) do
|
|
new_value = not socket.assigns.view_settings.member_include_email
|
|
|
|
socket
|
|
|> update_view_setting(:member_include_email, new_value)
|
|
|> apply_include_email_toggle(new_value)
|
|
|> load_members()
|
|
|> prepare_dynamic_cols()
|
|
|> update_selection_assigns()
|
|
|> push_field_selection_url()
|
|
end
|
|
|
|
# Flips the compact Member field. Besides re-rendering the rows, this changes
|
|
# which columns the manager offers (composite "Name" vs. Vorname/Nachname/E-Mail,
|
|
# §7b). The email visibility carries over between the two representations,
|
|
# analogously to the "include email" sub-toggle: composite + include-email ⇄ the
|
|
# E-Mail column being visible.
|
|
defp toggle_view_setting(socket, :compact_member) do
|
|
new_value = not socket.assigns.view_settings.compact_member
|
|
email_visible? = email_currently_visible?(socket)
|
|
|
|
socket
|
|
|> update_view_setting(:compact_member, new_value)
|
|
|> sync_include_email(new_value, email_visible?)
|
|
|> apply_member_toggle(new_value, email_visible?)
|
|
|> load_members()
|
|
|> prepare_dynamic_cols()
|
|
|> update_selection_assigns()
|
|
|> push_field_selection_url()
|
|
end
|
|
|
|
# Flips the compact Address field: offers the composite "Adresse" vs. the
|
|
# separate Straße/Hausnummer/PLZ/Ort columns, making the newly relevant columns
|
|
# visible and recomputing the offered set + visibility for the new mode.
|
|
defp toggle_view_setting(socket, :compact_address) do
|
|
new_value = not socket.assigns.view_settings.compact_address
|
|
|
|
fields = if new_value, do: [:address], else: [:street, :house_number, :postal_code, :city]
|
|
|
|
socket
|
|
|> update_view_setting(:compact_address, new_value)
|
|
|> apply_composite_toggle(fields)
|
|
|> load_members()
|
|
|> prepare_dynamic_cols()
|
|
|> update_selection_assigns()
|
|
|> push_field_selection_url()
|
|
end
|
|
|
|
# Whether the member email is currently surfaced: while the composite is on,
|
|
# either folded into the cell (include_email) or as a separate E-Mail column;
|
|
# while it is off, as its own column. Read before the flip, so `compact_member`
|
|
# still holds the previous mode.
|
|
defp email_currently_visible?(socket) do
|
|
if socket.assigns.compact_member do
|
|
socket.assigns.member_include_email or :email in socket.assigns.member_fields_visible
|
|
else
|
|
:email in socket.assigns.member_fields_visible
|
|
end
|
|
end
|
|
|
|
# Turning include_email OFF surfaces the email as a separate column; make it
|
|
# visible. Turning it ON folds the email into the cell, so the column is no
|
|
# longer offered (the recompute drops it).
|
|
defp apply_include_email_toggle(socket, false = _include_email) do
|
|
selection = Map.put(socket.assigns.user_field_selection, "email", true)
|
|
recompute_offered(socket, selection)
|
|
end
|
|
|
|
defp apply_include_email_toggle(socket, true = _include_email),
|
|
do: recompute_offered(socket, socket.assigns.user_field_selection)
|
|
|
|
# When switching to the composite, mirror the email state onto the include-email
|
|
# sub-toggle so the email keeps being surfaced; when switching away, the
|
|
# sub-toggle is not applicable.
|
|
defp sync_include_email(socket, true = _new_compact, email_visible?),
|
|
do: update_view_setting(socket, :member_include_email, email_visible?)
|
|
|
|
defp sync_include_email(socket, false = _new_compact, _email_visible?), do: socket
|
|
|
|
# Makes the now-relevant Member columns visible for the new mode. Turning the
|
|
# composite on surfaces "Name" (with the email either folded via include_email
|
|
# or kept as a separate column); turning it off surfaces Vorname/Nachname and
|
|
# carries the prior email visibility onto the E-Mail column.
|
|
defp apply_member_toggle(socket, true = _new_compact, email_visible?) do
|
|
selection =
|
|
socket.assigns.user_field_selection
|
|
|> Map.put("name", true)
|
|
|> Map.put("email", email_visible?)
|
|
|
|
recompute_offered(socket, selection)
|
|
end
|
|
|
|
defp apply_member_toggle(socket, false = _new_compact, email_visible?) do
|
|
selection =
|
|
socket.assigns.user_field_selection
|
|
|> Map.put("first_name", true)
|
|
|> Map.put("last_name", true)
|
|
|> Map.put("email", email_visible?)
|
|
|
|
recompute_offered(socket, selection)
|
|
end
|
|
|
|
# Sets the given columns visible, then recomputes the offered set + visibility.
|
|
defp apply_composite_toggle(socket, fields) do
|
|
selection =
|
|
Enum.reduce(fields, socket.assigns.user_field_selection, fn field, acc ->
|
|
Map.put(acc, Atom.to_string(field), true)
|
|
end)
|
|
|
|
recompute_offered(socket, selection)
|
|
end
|
|
|
|
# Recomputes the offered column list and the derived visibility for the current
|
|
# mode. `update_view_setting` has already updated the compact_* assigns, so
|
|
# `view_flags/1` reflects the new mode here.
|
|
defp recompute_offered(socket, selection) do
|
|
{cm, ie, ca} = view_flags(socket)
|
|
|
|
socket
|
|
|> assign(
|
|
:all_available_fields,
|
|
FieldVisibility.get_offered_fields(socket.assigns.all_custom_fields, cm, ie, ca)
|
|
)
|
|
|> assign_field_visibility(selection)
|
|
end
|
|
|
|
# Parses optional "highlight" URL param (member id for selected row styling). Returns nil if missing or invalid.
|
|
defp parse_highlight_param(nil), do: nil
|
|
defp parse_highlight_param(""), do: nil
|
|
|
|
defp parse_highlight_param(id) when is_binary(id) do
|
|
if String.length(id) <= @max_uuid_length and match?({:ok, _}, Ecto.UUID.cast(id)),
|
|
do: id,
|
|
else: nil
|
|
end
|
|
|
|
defp parse_highlight_param(_), do: nil
|
|
|
|
defp merge_fields_param_from_uri(params, nil), do: params
|
|
|
|
defp merge_fields_param_from_uri(params, %URI{query: query}) when is_binary(query) do
|
|
case URI.decode_query(query)["fields"] do
|
|
nil -> params
|
|
value -> Map.put(params, "fields", value)
|
|
end
|
|
end
|
|
|
|
defp merge_fields_param_from_uri(params, %URI{}), do: params
|
|
|
|
defp merge_fields_param_from_uri(params, url) when is_binary(url) do
|
|
case URI.parse(url).query do
|
|
nil ->
|
|
params
|
|
|
|
q ->
|
|
case URI.decode_query(q)["fields"] do
|
|
nil -> params
|
|
value -> Map.put(params, "fields", value)
|
|
end
|
|
end
|
|
end
|
|
|
|
defp merge_fields_param_from_uri(params, _), do: params
|
|
|
|
defp build_base_params(query, sort_field, sort_order) do
|
|
%{
|
|
"query" => query || "",
|
|
"sort_field" => normalize_sort_field(sort_field),
|
|
"sort_order" => normalize_sort_order(sort_order)
|
|
}
|
|
end
|
|
|
|
defp normalize_sort_field(nil), do: ""
|
|
defp normalize_sort_field(field) when is_atom(field), do: Atom.to_string(field)
|
|
defp normalize_sort_field(field) when is_binary(field), do: field
|
|
defp normalize_sort_field(_), do: ""
|
|
|
|
defp normalize_sort_order(nil), do: ""
|
|
defp normalize_sort_order(order) when is_atom(order), do: Atom.to_string(order)
|
|
defp normalize_sort_order(order) when is_binary(order), do: order
|
|
defp normalize_sort_order(_), do: ""
|
|
|
|
defp add_group_filters(params, group_filters) do
|
|
Enum.reduce(group_filters, params, fn {group_id_str, value}, acc ->
|
|
param_value = if value == :in, do: "in", else: "not_in"
|
|
Map.put(acc, "#{@group_filter_prefix}#{group_id_str}", param_value)
|
|
end)
|
|
end
|
|
|
|
defp add_cycle_status_filter(params, nil), do: params
|
|
defp add_cycle_status_filter(params, :paid), do: Map.put(params, "cycle_status_filter", "paid")
|
|
|
|
defp add_cycle_status_filter(params, :unpaid),
|
|
do: Map.put(params, "cycle_status_filter", "unpaid")
|
|
|
|
defp add_cycle_status_filter(params, _), do: params
|
|
|
|
defp add_show_current_cycle(params, true), do: Map.put(params, "show_current_cycle", "true")
|
|
defp add_show_current_cycle(params, _), do: params
|
|
|
|
defp add_boolean_filters(params, boolean_filters) do
|
|
Enum.reduce(boolean_filters, params, &add_boolean_filter/2)
|
|
end
|
|
|
|
defp add_boolean_filter({custom_field_id, filter_value}, acc) do
|
|
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
|
|
|
|
# -------------------------------------------------------------
|
|
# Loading members
|
|
# -------------------------------------------------------------
|
|
|
|
# Loads the first keyset page of the overview and resets the stream. All
|
|
# filtering and sorting run in PostgreSQL via the `:overview` read action
|
|
# (see OverviewQuery); the socket holds only the loaded window, never the
|
|
# whole table.
|
|
defp load_members(socket) do
|
|
# `count: true` runs one COUNT for the active filter set so the live region
|
|
# can announce the exact total matching count (§1.9), not just the loaded
|
|
# page size.
|
|
page = read_overview_page(socket, limit: @page_limit, count: true)
|
|
|
|
socket
|
|
|> assign(:members, index_by_id(page.results))
|
|
|> assign(:after_cursor, next_cursor(page))
|
|
|> assign(:more?, page.more?)
|
|
|> assign(:total_count, page.count || length(page.results))
|
|
|> AshPhoenix.LiveView.assign_page_and_stream_result(page,
|
|
results_key: :members,
|
|
page_key: :page,
|
|
stream_opts: [reset: true]
|
|
)
|
|
end
|
|
|
|
# Returns the members scroll container to the top after a sort/filter reset.
|
|
# A reset re-streams from page 1, so a user who was scrolled down would be left
|
|
# past the (now shorter) content with infinite scroll not re-arming; scrolling
|
|
# back to the top shows the new ordering from #1 and re-arms the loader. Only
|
|
# used on genuine resets, never on load_more (which must preserve position).
|
|
# The client handler (app.js) is a no-op if the container is not found.
|
|
defp scroll_list_to_top(socket), do: push_event(socket, "members:scroll-top", %{})
|
|
|
|
# Marks the table region busy (aria-busy, §1.10) for the reload triggered by a
|
|
# filter/sort/search patch, then hands off to the patch. `handle_params` clears
|
|
# the flag once the page has been (re)loaded.
|
|
defp push_reload(socket, path) do
|
|
socket
|
|
|> assign(:loading?, true)
|
|
|> push_patch(to: path, replace: true)
|
|
end
|
|
|
|
# Fetches the next keyset page (if any) and appends it to the stream and the
|
|
# loaded window. Triggered by phx-viewport-bottom; a no-op once the last page
|
|
# has been reached so the bottom sentinel stops fetching.
|
|
defp load_more(%{assigns: %{more?: false}} = socket), do: socket
|
|
|
|
defp load_more(socket) do
|
|
page = read_overview_page(socket, limit: @page_limit, after: socket.assigns.after_cursor)
|
|
|
|
socket =
|
|
Enum.reduce(page.results, socket, fn member, acc ->
|
|
stream_insert(acc, :members, member)
|
|
end)
|
|
|
|
socket
|
|
|> assign(:members, Map.merge(socket.assigns.members, index_by_id(page.results)))
|
|
|> assign(:after_cursor, next_cursor(page))
|
|
|> assign(:more?, page.more?)
|
|
|> assign(:page, %{page | results: nil})
|
|
end
|
|
|
|
# Loaded members are held as a `%{id => member}` map (not a list): appends on
|
|
# load_more stay O(page) via Map.merge instead of copying an ever-growing list
|
|
# with `++`, and a per-row selection re-render is an O(1) lookup by id.
|
|
defp index_by_id(members), do: Map.new(members, &{&1.id, &1})
|
|
|
|
# Re-inserts a single loaded member into the stream so its row re-renders with
|
|
# the current selection state (checkbox + selected highlight). Stream rows are
|
|
# not re-rendered on assign changes, so a selection toggle must re-stream the
|
|
# affected row.
|
|
defp restream_member(socket, id) do
|
|
case Map.get(socket.assigns[:members] || %{}, id) do
|
|
nil -> socket
|
|
member -> stream_insert(socket, :members, member)
|
|
end
|
|
end
|
|
|
|
# Re-inserts every loaded member so all visible rows reflect a bulk selection
|
|
# change (select-all / deselect-all).
|
|
defp restream_all_members(socket) do
|
|
Enum.reduce(Map.values(socket.assigns[:members] || %{}), socket, fn member, acc ->
|
|
stream_insert(acc, :members, member)
|
|
end)
|
|
end
|
|
|
|
# Reads one keyset page of the overview with display loads applied. Reads run
|
|
# as the real user actor so Ash policies are enforced.
|
|
defp read_overview_page(socket, page_opts) do
|
|
socket
|
|
|> overview_query()
|
|
|> Ash.read!(actor: current_actor(socket), page: page_opts)
|
|
end
|
|
|
|
# The cursor of the last loaded row, used as the `after` bound for the next
|
|
# page; nil once there are no further pages.
|
|
defp next_cursor(%{more?: true, results: results}) when results != [],
|
|
do: List.last(results).__metadata__.keyset
|
|
|
|
defp next_cursor(_page), do: nil
|
|
|
|
# Builds the `:overview` query (filter/sort via OverviewQuery) and layers on
|
|
# the display-only loads (custom field values, cycles, groups, fee type).
|
|
defp overview_query(socket) do
|
|
socket
|
|
|> overview_query_opts()
|
|
|> OverviewQuery.build()
|
|
|> Ash.Query.select(@overview_fields)
|
|
|> load_custom_field_values(compute_ids_to_load(socket))
|
|
|> MembershipFeeStatus.load_cycles_for_members(socket.assigns.show_current_cycle)
|
|
|> Ash.Query.load(groups: [:id, :name, :slug])
|
|
|> maybe_load_fee_type(socket)
|
|
end
|
|
|
|
defp maybe_load_fee_type(query, socket) do
|
|
if :membership_fee_type in socket.assigns.member_fields_visible or
|
|
socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do
|
|
Ash.Query.load(query, membership_fee_type: [:id, :name])
|
|
else
|
|
query
|
|
end
|
|
end
|
|
|
|
# Assembles the filter/sort options OverviewQuery understands from the current
|
|
# LiveView assigns.
|
|
defp overview_query_opts(socket) do
|
|
%{
|
|
search: socket.assigns.query,
|
|
group_filters: socket.assigns[:group_filters],
|
|
groups: socket.assigns[:groups],
|
|
fee_type_filters: socket.assigns[:fee_type_filters],
|
|
fee_types: socket.assigns[:fee_types],
|
|
boolean_custom_field_filters: socket.assigns.boolean_custom_field_filters,
|
|
boolean_custom_fields: socket.assigns.boolean_custom_fields,
|
|
date_filters: socket.assigns.date_filters,
|
|
date_custom_fields: socket.assigns[:date_custom_fields],
|
|
cycle_status_filter: socket.assigns.cycle_status_filter,
|
|
show_current_cycle: socket.assigns.show_current_cycle,
|
|
sort_field: socket.assigns.sort_field,
|
|
sort_order: socket.assigns.sort_order,
|
|
custom_fields: socket.assigns.all_custom_fields
|
|
}
|
|
end
|
|
|
|
# Collects every custom field UUID whose values must be loaded for a given
|
|
# render — visible columns plus any active boolean or date filter. Kept as a
|
|
# standalone helper so load_members/1 stays under the credo complexity bar.
|
|
defp compute_ids_to_load(socket) do
|
|
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)
|
|
|
|
date_custom_fields = socket.assigns[:date_custom_fields] || []
|
|
|
|
active_date_filter_ids =
|
|
DateFilter.active_custom_field_ids(
|
|
socket.assigns.date_filters,
|
|
date_custom_fields
|
|
)
|
|
|
|
(visible_custom_field_ids ++ active_boolean_filter_ids ++ active_date_filter_ids)
|
|
|> Enum.uniq()
|
|
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 toggle_order(:asc), do: :desc
|
|
defp toggle_order(:desc), do: :asc
|
|
defp toggle_order(nil), do: :asc
|
|
|
|
defp valid_sort_field?(field) when is_atom(field) do
|
|
# :groups is in computed_member_fields() but can be sorted
|
|
# Only :membership_fee_status should be blocked
|
|
if field == :membership_fee_status do
|
|
false
|
|
else
|
|
valid_sort_field_db_or_custom?(field)
|
|
end
|
|
end
|
|
|
|
defp valid_sort_field?(field) when is_binary(field) do
|
|
# "groups" is in computed_member_fields() but can be sorted
|
|
# Only "membership_fee_status" should be blocked
|
|
if field == "membership_fee_status" do
|
|
false
|
|
else
|
|
valid_sort_field_db_or_custom?(field)
|
|
end
|
|
end
|
|
|
|
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) or
|
|
field in [:groups, :membership_fee_type, :name, :address]
|
|
end
|
|
|
|
defp valid_sort_field_db_or_custom?(field) when is_binary(field) do
|
|
normalized =
|
|
cond do
|
|
field == "groups" -> :groups
|
|
field == "membership_fee_type" -> :membership_fee_type
|
|
field == "name" -> :name
|
|
field == "address" -> :address
|
|
true -> safe_member_field_atom_only(field)
|
|
end
|
|
|
|
(normalized != nil and valid_sort_field_db_or_custom?(normalized)) or
|
|
custom_field_sort?(field)
|
|
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_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 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)
|
|
old_field = socket.assigns.sort_field
|
|
|
|
socket
|
|
|> assign(:sort_field, field)
|
|
|> assign(:sort_order, order)
|
|
|> assign(:sort_needs_update, old_field != field or socket.assigns.sort_order != order)
|
|
|> assign(:previous_sort_field, old_field)
|
|
end
|
|
|
|
defp maybe_update_sort(socket, _), do: socket
|
|
|
|
defp determine_field(default, ""), do: default
|
|
defp determine_field(default, nil), do: default
|
|
|
|
# Computed/pseudo fields that are nonetheless sortable (they resolve to DB
|
|
# sort keys in OverviewQuery): groups aggregate and the composite Name/Address
|
|
# columns. Other computed fields (e.g. membership_fee_status) are not sortable.
|
|
@sortable_computed_fields [:groups, :name, :address]
|
|
|
|
defp determine_field(default, sf) when is_binary(sf) do
|
|
sortable_strings = Enum.map(@sortable_computed_fields, &Atom.to_string/1)
|
|
|
|
if sf in sortable_strings do
|
|
String.to_existing_atom(sf)
|
|
else
|
|
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
|
|
end
|
|
|
|
defp determine_field(default, sf) when is_atom(sf) do
|
|
if sf in @sortable_computed_fields do
|
|
sf
|
|
else
|
|
if sf in FieldVisibility.computed_member_fields(),
|
|
do: default,
|
|
else: determine_field_after_computed_check(default, sf)
|
|
end
|
|
end
|
|
|
|
defp determine_field(default, _), do: default
|
|
|
|
defp determine_field_after_computed_check(default, sf) when is_binary(sf) do
|
|
cond do
|
|
sf == "groups" ->
|
|
:groups
|
|
|
|
custom_field_sort?(sf) ->
|
|
if valid_sort_field?(sf), do: sf, else: default
|
|
|
|
true ->
|
|
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 maybe_update_group_filters(socket, params) when is_map(params) do
|
|
prefix = @group_filter_prefix
|
|
prefix_len = String.length(prefix)
|
|
|
|
group_param_entries =
|
|
params
|
|
|> Enum.filter(fn {key, _} ->
|
|
key_str = to_string(key)
|
|
String.starts_with?(key_str, prefix)
|
|
end)
|
|
|
|
filters =
|
|
Enum.reduce(group_param_entries, %{}, fn {key, value_str}, acc ->
|
|
add_group_filter_entry(acc, key, value_str, prefix_len)
|
|
end)
|
|
|
|
valid_group_ids =
|
|
socket.assigns.groups
|
|
|> Enum.map(&normalize_uuid_string(to_string(&1.id)))
|
|
|> Enum.reject(&is_nil/1)
|
|
|> MapSet.new()
|
|
|> MapSet.to_list()
|
|
|
|
assign(socket, :group_filters, Map.take(filters, valid_group_ids))
|
|
end
|
|
|
|
defp maybe_update_fee_type_filters(socket, params) when is_map(params) do
|
|
prefix = @fee_type_filter_prefix
|
|
prefix_len = String.length(prefix)
|
|
|
|
fee_type_param_entries =
|
|
params
|
|
|> Enum.filter(fn {key, _} ->
|
|
key_str = to_string(key)
|
|
String.starts_with?(key_str, prefix)
|
|
end)
|
|
|
|
filters =
|
|
Enum.reduce(fee_type_param_entries, %{}, fn {key, value_str}, acc ->
|
|
add_fee_type_filter_entry(acc, key, value_str, prefix_len)
|
|
end)
|
|
|
|
valid_fee_type_ids =
|
|
socket.assigns.fee_types
|
|
|> Enum.map(&normalize_uuid_string(to_string(&1.id)))
|
|
|> Enum.reject(&is_nil/1)
|
|
|> MapSet.new()
|
|
|> MapSet.to_list()
|
|
|
|
assign(socket, :fee_type_filters, Map.take(filters, valid_fee_type_ids))
|
|
end
|
|
|
|
defp add_fee_type_filter_entry(acc, key, value_str, prefix_len) do
|
|
key_str = to_string(key)
|
|
raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len)
|
|
fee_type_id_str = normalize_uuid_string(raw_id)
|
|
valid_id? = fee_type_id_str && String.length(fee_type_id_str) <= @max_uuid_length
|
|
|
|
if valid_id? do
|
|
case FilterParams.parse_in_not_in_value(value_str) do
|
|
nil -> acc
|
|
value -> Map.put(acc, fee_type_id_str, value)
|
|
end
|
|
else
|
|
acc
|
|
end
|
|
end
|
|
|
|
defp add_group_filter_entry(acc, key, value_str, prefix_len) do
|
|
key_str = to_string(key)
|
|
raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len)
|
|
group_id_str = normalize_uuid_string(raw_id)
|
|
valid_id? = group_id_str && String.length(group_id_str) <= @max_uuid_length
|
|
|
|
if valid_id? do
|
|
case FilterParams.parse_in_not_in_value(value_str) do
|
|
nil -> acc
|
|
value -> Map.put(acc, group_id_str, value)
|
|
end
|
|
else
|
|
acc
|
|
end
|
|
end
|
|
|
|
# Normalize UUID string so URL params match valid_ids (lowercase, canonical format)
|
|
defp normalize_uuid_string(raw) when is_binary(raw) do
|
|
case Ecto.UUID.cast(String.trim(raw)) do
|
|
{:ok, uuid} -> to_string(uuid)
|
|
_ -> raw
|
|
end
|
|
end
|
|
|
|
defp normalize_uuid_string(_), do: 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
|
|
|
|
# URL params are the source of truth for filter state on every navigation.
|
|
# When no date filter params are present, this falls through to the
|
|
# active_only default — exactly the spec behavior for fresh load (§1.1).
|
|
defp maybe_update_date_filters(socket, params) when is_map(params) do
|
|
date_custom_fields = socket.assigns[:date_custom_fields] || []
|
|
assign(socket, :date_filters, DateFilter.from_params(params, date_custom_fields))
|
|
end
|
|
|
|
# -------------------------------------------------------------
|
|
# Custom Field Value Helpers
|
|
# -------------------------------------------------------------
|
|
|
|
def get_custom_field_value(member, custom_field) do
|
|
CustomFieldValueLookup.find_by_field(member, custom_field)
|
|
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 CustomFieldValueLookup.find_by_id(member, custom_field_id_str) do
|
|
nil -> false
|
|
cfv -> extract_boolean_value(cfv.value) == filter_value
|
|
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
|
|
selected_members = socket.assigns.selected_members
|
|
# The selection may span members beyond the loaded page (after select-all),
|
|
# so its size is the MapSet size, not a count over the loaded window.
|
|
selected_count = MapSet.size(selected_members)
|
|
any_selected? = selected_count > 0
|
|
|
|
# Scope drives the trigger label: the selection when present, otherwise the
|
|
# whole list (filtered, when a search term or any filter is active).
|
|
scope =
|
|
cond do
|
|
any_selected? -> :selection
|
|
filters_active?(socket.assigns) -> :filtered
|
|
true -> :all
|
|
end
|
|
|
|
# No DB work here: the mailto recipient list is computed lazily when the
|
|
# bulk-actions dropdown opens (assign_mailto_recipients/1), so a selection or
|
|
# filter change never reads members for a link the user may never open.
|
|
socket
|
|
|> assign(:selected_count, selected_count)
|
|
|> assign(:scope, scope)
|
|
|> assign_export_payload()
|
|
end
|
|
|
|
# Fetches the mailto BCC recipients for the current bulk scope. Called only when
|
|
# the bulk-actions dropdown opens (the mailto item, a native anchor, is rendered
|
|
# only then), keeping selection toggles free of DB reads.
|
|
defp assign_mailto_recipients(socket) do
|
|
selected_count = MapSet.size(socket.assigns.selected_members)
|
|
|
|
# Size of the whole bulk scope (§1.17): the selection, or the full filtered
|
|
# total — never just the loaded page.
|
|
scope_size = if selected_count > 0, do: selected_count, else: socket.assigns.total_count
|
|
cap = Mv.Constants.max_mailto_bulk_recipients()
|
|
mailto_disabled? = scope_size >= cap
|
|
|
|
# Mailto recipients are bounded by the cap; only re-query the (small) scope
|
|
# when it is within the cap, otherwise the link is disabled anyway.
|
|
recipient_emails = if mailto_disabled?, do: [], else: scope_emails(socket, limit: cap)
|
|
recipient_count = if mailto_disabled?, do: scope_size, else: length(recipient_emails)
|
|
|
|
# RFC 6068: mailto URI params must use %20 for spaces, not + (encode_www_form uses +)
|
|
mailto_bcc =
|
|
recipient_emails
|
|
|> Enum.join(", ")
|
|
|> URI.encode_www_form()
|
|
|> String.replace("+", "%20")
|
|
|
|
socket
|
|
|> assign(:recipient_count, recipient_count)
|
|
|> assign(:mailto_disabled?, mailto_disabled?)
|
|
|> assign(:mailto_bcc, mailto_bcc)
|
|
end
|
|
|
|
# Formatted "Name <email>" recipients for the current bulk scope, re-queried
|
|
# from the DB so the set spans the whole selection / filtered total rather than
|
|
# only the loaded page (§1.17). Members without an email are excluded.
|
|
defp scope_emails(socket, opts \\ []) do
|
|
socket
|
|
|> scope_members(opts)
|
|
|> Enum.filter(fn member -> member.email && member.email != "" end)
|
|
|> Enum.map(&format_member_email/1)
|
|
end
|
|
|
|
# MapSet of every member id matching the active filters (no pagination), used
|
|
# by select-all so the selection spans the full filtered set (§1.17).
|
|
defp all_filtered_member_ids(socket) do
|
|
socket
|
|
|> overview_query_opts()
|
|
|> Map.put(:sort_field, nil)
|
|
|> OverviewQuery.build()
|
|
|> Ash.Query.select([:id])
|
|
|> Ash.read!(actor: current_actor(socket))
|
|
|> MapSet.new(& &1.id)
|
|
end
|
|
|
|
# Members matching the current bulk scope: the selected members when any are
|
|
# selected, otherwise every member matching the active filters. Re-queried as
|
|
# the real user actor so policies are enforced.
|
|
defp scope_members(socket, opts) do
|
|
selected = socket.assigns.selected_members
|
|
|
|
query =
|
|
if MapSet.size(selected) > 0 do
|
|
ids = MapSet.to_list(selected)
|
|
|
|
Mv.Membership.Member
|
|
|> Ash.Query.for_read(:overview)
|
|
|> Ash.Query.filter(expr(id in ^ids))
|
|
else
|
|
socket
|
|
|> overview_query_opts()
|
|
|> Map.put(:sort_field, nil)
|
|
|> OverviewQuery.build()
|
|
end
|
|
|> Ash.Query.select([:id, :first_name, :last_name, :email])
|
|
|
|
query = if opts[:limit], do: Ash.Query.limit(query, opts[:limit]), else: query
|
|
|
|
Ash.read!(query, actor: current_actor(socket))
|
|
end
|
|
|
|
@doc """
|
|
Returns true when the member list is restricted by a non-empty search term or
|
|
any active filter (cycle status, group, fee type, boolean custom field, or a
|
|
date filter differing from the default). Drives the "(gefiltert)" vs "(alle)"
|
|
trigger label and reads only assigns — no DB access.
|
|
"""
|
|
def filters_active?(assigns) do
|
|
search_active?(assigns) or selection_filters_active?(assigns) or date_filter_active?(assigns)
|
|
end
|
|
|
|
defp search_active?(assigns) do
|
|
query = assigns[:query]
|
|
is_binary(query) and query != ""
|
|
end
|
|
|
|
defp selection_filters_active?(assigns) do
|
|
not is_nil(assigns[:cycle_status_filter]) or
|
|
map_size(assigns[:group_filters] || %{}) > 0 or
|
|
map_size(assigns[:fee_type_filters] || %{}) > 0 or
|
|
map_size(assigns[:boolean_custom_field_filters] || %{}) > 0
|
|
end
|
|
|
|
defp date_filter_active?(assigns) do
|
|
(assigns[:date_filters] || DateFilter.default()) != DateFilter.default()
|
|
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))
|
|
|
|
member_fields_with_groups =
|
|
build_export_member_fields_list(
|
|
ordered_member_fields_db,
|
|
socket.assigns[:member_fields_visible]
|
|
)
|
|
|
|
# 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(member_fields_with_groups, fn
|
|
f when is_atom(f) -> Atom.to_string(f)
|
|
f when is_binary(f) -> f
|
|
end),
|
|
computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1),
|
|
custom_field_ids: ordered_custom_field_ids,
|
|
column_order:
|
|
export_column_order(
|
|
ordered_member_fields_db,
|
|
ordered_computed_fields,
|
|
ordered_custom_field_ids,
|
|
:membership_fee_type in socket.assigns[:member_fields_visible],
|
|
:groups in socket.assigns[:member_fields_visible]
|
|
),
|
|
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 expand_db_string_for_export(f, membership_fee_type_visible, computed_strings) do
|
|
if f == "membership_fee_start_date" do
|
|
extra =
|
|
if(membership_fee_type_visible, do: ["membership_fee_type"], else: []) ++
|
|
if "membership_fee_status" in computed_strings, do: ["membership_fee_status"], else: []
|
|
|
|
[f] ++ extra
|
|
else
|
|
[f]
|
|
end
|
|
end
|
|
|
|
defp build_export_member_fields_list(ordered_db, member_fields_visible) do
|
|
with_extras =
|
|
Enum.flat_map(ordered_db, fn f ->
|
|
if f == :membership_fee_start_date and
|
|
:membership_fee_type in (member_fields_visible || []) do
|
|
[f, :membership_fee_type]
|
|
else
|
|
[f]
|
|
end
|
|
end)
|
|
|
|
# If fee type is visible but start_date was not in the list, append it
|
|
with_extras =
|
|
if :membership_fee_type in (member_fields_visible || []) and
|
|
:membership_fee_type not in with_extras do
|
|
with_extras ++ [:membership_fee_type]
|
|
else
|
|
with_extras
|
|
end
|
|
|
|
if :groups in (member_fields_visible || []), do: with_extras ++ [:groups], else: with_extras
|
|
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
|
|
# Build a single ordered list that matches the table order:
|
|
# - DB fields in Mv.Constants.member_fields() order (already pre-filtered as ordered_member_fields_db)
|
|
# - membership_fee_type and membership_fee_status inserted after membership_fee_start_date when visible
|
|
# - groups appended before custom fields when visible
|
|
# - custom fields appended in the same order as table (already ordered_custom_field_ids)
|
|
defp export_column_order(
|
|
ordered_member_fields_db,
|
|
ordered_computed_fields,
|
|
ordered_custom_field_ids,
|
|
membership_fee_type_visible,
|
|
groups_visible
|
|
) do
|
|
db_strings = Enum.map(ordered_member_fields_db, &Atom.to_string/1)
|
|
computed_strings = Enum.map(ordered_computed_fields, &Atom.to_string/1)
|
|
|
|
# Place membership_fee_type and membership_fee_status after membership_fee_start_date when present
|
|
db_with_extras =
|
|
Enum.flat_map(
|
|
db_strings,
|
|
&expand_db_string_for_export(&1, membership_fee_type_visible, computed_strings)
|
|
)
|
|
|
|
# If fee type is visible but start_date was not in the list, append it before computed/groups
|
|
db_with_extras =
|
|
if membership_fee_type_visible and "membership_fee_type" not in db_with_extras do
|
|
db_with_extras ++ ["membership_fee_type"]
|
|
else
|
|
db_with_extras
|
|
end
|
|
|
|
# Any remaining computed fields not inserted above (future-proof)
|
|
remaining_computed =
|
|
computed_strings
|
|
|> Enum.reject(&(&1 in db_with_extras))
|
|
|
|
result = db_with_extras ++ remaining_computed
|
|
result = if groups_visible, do: result ++ ["groups"], else: result
|
|
result ++ ordered_custom_field_ids
|
|
end
|
|
end
|