feat(member): stream the overview with keyset infinite scroll instead of loading every member

This commit is contained in:
Simon 2026-07-03 11:31:44 +02:00
parent b09cdf7f3a
commit b79d7ac9ea
13 changed files with 1067 additions and 451 deletions

View file

@ -30,8 +30,6 @@ defmodule MvWeb.MemberLive.Index do
import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Membership
alias Mv.Membership.CustomFieldSort
alias Mv.Membership.Member, as: MemberResource
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.DateFormatter
@ -42,6 +40,7 @@ defmodule MvWeb.MemberLive.Index do
alias MvWeb.MemberLive.Index.FilterParams
alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.MemberLive.Index.MembershipFeeStatus
alias MvWeb.MemberLive.Index.OverviewQuery
require Ash.Query
require Logger
@ -173,11 +172,29 @@ defmodule MvWeb.MemberLive.Index do
)
|> 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)
|> 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
# -----------------------------------------------------------------
@ -208,12 +225,15 @@ defmodule MvWeb.MemberLive.Index do
{:noreply,
socket
|> assign(:selected_members, selected)
|> restream_member(id)
|> 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()
# 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
@ -225,6 +245,33 @@ defmodule MvWeb.MemberLive.Index do
{: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
@ -236,6 +283,7 @@ defmodule MvWeb.MemberLive.Index do
socket
|> assign(:show_current_cycle, new_show_current)
|> load_members()
|> scroll_list_to_top()
|> update_selection_assigns()
query_params =
@ -247,21 +295,16 @@ defmodule MvWeb.MemberLive.Index do
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
{:noreply, push_reload(socket, new_path)}
end
@impl true
def handle_event("copy_emails", _params, socket) do
members = socket.assigns.members
selected_ids = socket.assigns.selected_members
any_selected? = Enum.any?(members, &MapSet.member?(selected_ids, &1.id))
# Recipients follow the current scope: the selection when present, otherwise
# every member in the (filtered) list. Members without an email are excluded
# in both cases (unchanged missing-email handling). With no selection we no
# longer hard-stop with "No members selected" — we act on the scope; the
# empty-recipient feedback below is preserved.
formatted_emails = scope_member_emails(members, selected_ids, any_selected?)
# 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
@ -310,6 +353,7 @@ defmodule MvWeb.MemberLive.Index do
|> 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
@ -325,7 +369,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns[:fields_in_url?] || false
)
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
{:noreply, push_reload(socket, ~p"/members?#{query_params}")}
end
# -----------------------------------------------------------------
@ -347,6 +391,7 @@ defmodule MvWeb.MemberLive.Index do
socket
|> assign(:query, q)
|> load_members()
|> scroll_list_to_top()
|> update_selection_assigns()
query_params =
@ -358,7 +403,7 @@ defmodule MvWeb.MemberLive.Index do
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
{:noreply, push_reload(socket, new_path)}
end
@impl true
@ -367,6 +412,7 @@ defmodule MvWeb.MemberLive.Index do
socket
|> assign(:cycle_status_filter, filter)
|> load_members()
|> scroll_list_to_top()
|> update_selection_assigns()
query_params =
@ -377,7 +423,7 @@ defmodule MvWeb.MemberLive.Index do
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
{:noreply, push_reload(socket, new_path)}
end
@impl true
@ -393,6 +439,7 @@ defmodule MvWeb.MemberLive.Index do
socket
|> assign(:boolean_custom_field_filters, updated_filters)
|> load_members()
|> scroll_list_to_top()
|> update_selection_assigns()
query_params =
@ -403,7 +450,7 @@ defmodule MvWeb.MemberLive.Index do
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
{:noreply, push_reload(socket, new_path)}
end
@impl true
@ -421,6 +468,7 @@ defmodule MvWeb.MemberLive.Index do
socket
|> assign(:group_filters, group_filters)
|> load_members()
|> scroll_list_to_top()
|> update_selection_assigns()
query_params =
@ -431,7 +479,7 @@ defmodule MvWeb.MemberLive.Index do
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
{:noreply, push_reload(socket, new_path)}
end
@impl true
@ -449,6 +497,7 @@ defmodule MvWeb.MemberLive.Index do
socket
|> assign(:fee_type_filters, fee_type_filters)
|> load_members()
|> scroll_list_to_top()
|> update_selection_assigns()
query_params =
@ -459,7 +508,7 @@ defmodule MvWeb.MemberLive.Index do
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
{:noreply, push_reload(socket, new_path)}
end
@impl true
@ -468,6 +517,7 @@ defmodule MvWeb.MemberLive.Index do
socket
|> assign(:date_filters, new_date_filters)
|> load_members()
|> scroll_list_to_top()
|> update_selection_assigns()
query_params =
@ -478,7 +528,7 @@ defmodule MvWeb.MemberLive.Index do
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
{:noreply, push_reload(socket, new_path)}
end
# Backward compatibility: tuple form delegates to map form
@ -537,6 +587,7 @@ defmodule MvWeb.MemberLive.Index do
|> 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 =
@ -547,7 +598,7 @@ defmodule MvWeb.MemberLive.Index do
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
{:noreply, push_reload(socket, new_path)}
end
@impl true
@ -691,6 +742,9 @@ defmodule MvWeb.MemberLive.Index do
|> 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
@ -806,7 +860,7 @@ defmodule MvWeb.MemberLive.Index do
|> maybe_add_field_selection(socket.assigns[:user_field_selection], true)
new_path = ~p"/members?#{query_params}"
push_patch(socket, to: new_path, replace: true)
push_reload(socket, new_path)
end
defp update_session_field_selection(socket, selection) do
@ -975,81 +1029,145 @@ defmodule MvWeb.MemberLive.Index do
# 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
search_query = socket.assigns.query
# `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)
query =
Mv.Membership.Member
|> Ash.Query.new()
|> Ash.Query.select(@overview_fields)
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
query = load_custom_field_values(query, compute_ids_to_load(socket))
# 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", %{})
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
# 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
# Load groups for each member (id, name, slug only)
query =
Ash.Query.load(query, groups: [:id, :name, :slug])
# 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
# Load membership_fee_type when the column is visible or when sorting by it
query =
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
defp load_more(socket) do
page = read_overview_page(socket, limit: @page_limit, after: socket.assigns.after_cursor)
query = apply_search_filter(query, search_query)
socket =
Enum.reduce(page.results, socket, fn member, acc ->
stream_insert(acc, :members, member)
end)
query = apply_group_filters(query, socket.assigns[:group_filters], socket.assigns[:groups])
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
query =
apply_fee_type_filters(query, socket.assigns[:fee_type_filters], socket.assigns[:fee_types])
# 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})
# Built-in date filters (join_date, exit_date) are pushed to the DB so
# excluded rows never reach the BEAM. The active_only default is part of
# this — fresh load returns only members without an exit_date or with an
# exit_date strictly in the future.
query =
DateFilter.apply_ash_filter(query, socket.assigns.date_filters)
# 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
# Use ALL custom fields for sorting (not just show_in_overview subset)
custom_fields_for_sort = socket.assigns.all_custom_fields
# 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
{query, sort_after_load} =
maybe_sort(
query,
socket.assigns.sort_field,
socket.assigns.sort_order,
custom_fields_for_sort
)
# 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
# Errors in handle_params are handled by Phoenix LiveView
actor = current_actor(socket)
members = Ash.read!(query, actor: actor)
# 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
# Custom field values are already filtered at the database level in load_custom_field_values/2
# No need for in-memory filtering anymore
defp next_cursor(_page), do: nil
members = apply_in_memory_filters(members, socket)
# 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
# Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked)
# Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status
members =
if sort_after_load and
socket.assigns.sort_field != :membership_fee_status do
sort_members_in_memory(
members,
socket.assigns.sort_field,
socket.assigns.sort_order,
custom_fields_for_sort
)
else
members
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
assign(socket, :members, members)
# 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
@ -1083,24 +1201,6 @@ defmodule MvWeb.MemberLive.Index do
|> Enum.uniq()
end
# Post-DB filtering: cycle status, boolean custom fields, and custom date
# fields. Date custom fields are last so they see the already-narrowed list.
defp apply_in_memory_filters(members, socket) do
members
|> apply_cycle_status_filter(
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
)
|> apply_boolean_custom_field_filters(
socket.assigns.boolean_custom_field_filters,
socket.assigns.all_custom_fields
)
|> DateFilter.apply_in_memory(
socket.assigns.date_filters,
socket.assigns[:date_custom_fields] || []
)
end
defp load_custom_field_values(query, []), do: query
defp load_custom_field_values(query, custom_field_ids) do
@ -1117,166 +1217,10 @@ defmodule MvWeb.MemberLive.Index do
# Helper Functions
# -------------------------------------------------------------
defp apply_search_filter(query, search_query) do
if search_query && String.trim(search_query) != "" do
query
|> MemberResource.fuzzy_search(%{query: search_query})
else
query
end
end
# Multiple group filters combine with AND: member must match all selected group conditions.
defp apply_group_filters(query, group_filters, _groups) when group_filters == %{}, do: query
defp apply_group_filters(query, group_filters, groups) do
valid_ids =
groups
|> Enum.map(&normalize_uuid_string(to_string(&1.id)))
|> Enum.reject(&is_nil/1)
|> MapSet.new()
Enum.reduce(group_filters, query, fn {group_id_str, value}, q ->
member? = MapSet.member?(valid_ids, group_id_str)
if member? do
apply_one_group_filter(q, group_id_str, value)
else
q
end
end)
end
defp apply_one_group_filter(query, _group_id_str, nil), do: query
defp apply_one_group_filter(query, group_id_str, :in) do
case Ecto.UUID.cast(group_id_str) do
{:ok, group_uuid} ->
Ash.Query.filter(query, expr(exists(member_groups, group_id == ^group_uuid)))
_ ->
query
end
end
defp apply_one_group_filter(query, group_id_str, :not_in) do
case Ecto.UUID.cast(group_id_str) do
{:ok, group_uuid} ->
Ash.Query.filter(query, expr(not exists(member_groups, group_id == ^group_uuid)))
_ ->
query
end
end
defp apply_one_group_filter(query, _, _), do: query
# Fee type filters: :in selections combine with OR (member has any of the selected types);
# :not_in selections combine with AND (member must not have type A and not have type B).
defp apply_fee_type_filters(query, fee_type_filters, _fee_types) when fee_type_filters == %{},
do: query
defp apply_fee_type_filters(query, fee_type_filters, fee_types) do
valid_ids =
fee_types
|> Enum.map(&normalize_uuid_string(to_string(&1.id)))
|> Enum.reject(&is_nil/1)
|> MapSet.new()
{in_id_strs, not_in_filters} =
fee_type_filters
|> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end)
|> Enum.split_with(fn {_, value} -> value == :in end)
in_uuids =
in_id_strs
|> Enum.map(fn {id_str, _} -> id_str end)
|> Enum.map(&Ecto.UUID.cast/1)
|> Enum.filter(&match?({:ok, _}, &1))
|> Enum.map(fn {:ok, uuid} -> uuid end)
query =
if in_uuids == [] do
query
else
Ash.Query.filter(query, expr(membership_fee_type_id in ^in_uuids))
end
Enum.reduce(not_in_filters, query, fn {fee_type_id_str, _}, q ->
apply_one_fee_type_filter(q, fee_type_id_str, :not_in)
end)
end
defp apply_one_fee_type_filter(query, fee_type_id_str, :not_in) do
case Ecto.UUID.cast(fee_type_id_str) do
{:ok, fee_type_uuid} ->
Ash.Query.filter(
query,
expr(membership_fee_type_id != ^fee_type_uuid or is_nil(membership_fee_type_id))
)
_ ->
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
# :groups is in computed_member_fields() but can be sorted in-memory
# Only :membership_fee_status should be blocked from sorting
if field == :membership_fee_status or field == "membership_fee_status" do
{query, false}
else
apply_sort_to_query(query, field, order)
end
end
defp apply_sort_to_query(query, field, order) do
cond do
# Groups sort -> after load (in memory)
field in [:groups, "groups"] ->
{query, true}
# Membership fee type sort -> by related name at DB
field in [:membership_fee_type, "membership_fee_type"] ->
{Ash.Query.sort(query, [{"membership_fee_type.name", order}]), 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
# :groups is in computed_member_fields() but can be sorted
# Only :membership_fee_status should be blocked
@ -1331,19 +1275,6 @@ defmodule MvWeb.MemberLive.Index do
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
@ -1354,91 +1285,6 @@ defmodule MvWeb.MemberLive.Index do
|> Enum.filter(&(&1 != nil))
end
defp sort_members_in_memory(members, field, order, custom_fields) do
if field in [:groups, "groups"] do
sort_members_by_groups(members, order)
else
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
end
defp sort_members_by_groups(members, order) do
# Members with groups first, then by first group name alphabetically (min = first by sort order)
first_group_name = fn member ->
(member.groups || [])
|> Enum.map(& &1.name)
|> Enum.min(fn -> nil end)
end
members
|> Enum.sort_by(fn member ->
name = first_group_name.(member)
# Nil (no groups) sorts last in asc, first in desc
{name == nil, name || ""}
end)
|> then(fn list -> if order == :desc, do: Enum.reverse(list), else: list 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 ->
not empty_value?(cfv.value, 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)
CustomFieldSort.sort_key(cfv.value, custom_field.value_type)
end)
if order == :desc, do: Enum.reverse(sorted), else: sorted
end
defp empty_value?(%Ash.Union{value: value, type: type}, _expected_type),
do: empty_value?(value, type)
defp empty_value?(nil, _type), do: true
defp empty_value?(value, type) when type in [:string, :email] and is_binary(value),
do: String.trim(value) == ""
defp empty_value?(_value, _type), do: false
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)
@ -1800,11 +1646,11 @@ defmodule MvWeb.MemberLive.Index do
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))
# 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).
@ -1815,13 +1661,31 @@ defmodule MvWeb.MemberLive.Index do
true -> :all
end
# Copy/Mailto recipients: the members in scope that have a usable email.
# With a selection that is the selected subset (existing behaviour); without
# a selection it is every member in scope (deliberate behaviour change). In
# both cases members without an email are excluded, exactly as today's
# format_selected_member_emails does for the selection case.
recipient_emails = scope_member_emails(members, selected_members, any_selected?)
recipient_count = length(recipient_emails)
# 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 =
@ -1830,29 +1694,60 @@ defmodule MvWeb.MemberLive.Index do
|> URI.encode_www_form()
|> String.replace("+", "%20")
mailto_disabled? = recipient_count >= Mv.Constants.max_mailto_bulk_recipients()
socket
|> assign(:selected_count, selected_count)
|> assign(:scope, scope)
|> assign(:recipient_count, recipient_count)
|> assign(:mailto_disabled?, mailto_disabled?)
|> assign(:mailto_bcc, mailto_bcc)
|> assign_export_payload()
end
# Returns the formatted "Name <email>" recipient list for the current scope:
# the selected members when any are selected, otherwise every member in the
# (filtered) list. Members without an email are excluded in both cases.
defp scope_member_emails(members, selected_members, true = _any_selected?),
do: format_selected_member_emails(members, selected_members)
defp scope_member_emails(members, _selected_members, false = _any_selected?) do
members
# 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