feat(member-live): wire date filters into LiveView lifecycle

This commit is contained in:
Moritz 2026-05-20 16:28:17 +02:00
parent ddd4a9a878
commit e3295ab4b5
10 changed files with 1037 additions and 140 deletions

View file

@ -36,6 +36,8 @@ defmodule MvWeb.MemberLive.Index do
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
@ -87,6 +89,13 @@ defmodule MvWeb.MemberLive.Index do
|> 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
@ -143,6 +152,8 @@ defmodule MvWeb.MemberLive.Index do
|> 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)
@ -448,6 +459,25 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, push_patch(socket, to: new_path, replace: true)}
end
@impl true
def handle_info({:date_filters_changed, new_date_filters}, socket) do
socket =
socket
|> assign(:date_filters, new_date_filters)
|> load_members()
|> 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_patch(socket, to: new_path, replace: true)}
end
# Backward compatibility: tuple form delegates to map form
def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
handle_info(
@ -502,6 +532,7 @@ defmodule MvWeb.MemberLive.Index do
|> 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()
|> update_selection_assigns()
@ -632,6 +663,7 @@ defmodule MvWeb.MemberLive.Index do
|> 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"])
@ -683,7 +715,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters,
socket.assigns.user_field_selection,
socket.assigns[:visible_custom_field_ids] || []
socket.assigns[:visible_custom_field_ids] || [],
socket.assigns[:date_filters]
}
end
@ -783,7 +816,12 @@ defmodule MvWeb.MemberLive.Index do
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)
add_boolean_filters(base_params, opts.boolean_filters || %{})
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
@ -795,7 +833,8 @@ defmodule MvWeb.MemberLive.Index do
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] || %{}
fee_type_filters: socket.assigns[:fee_type_filters] || %{},
date_filters: socket.assigns.date_filters
}
|> Map.merge(overrides)
end
@ -941,26 +980,7 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.new()
|> Ash.Query.select(@overview_fields)
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
boolean_custom_fields_map =
socket.assigns.boolean_custom_fields
|> Map.new(fn cf -> {to_string(cf.id), cf} end)
active_boolean_filter_ids =
socket.assigns.boolean_custom_field_filters
|> Map.keys()
|> Enum.filter(fn id_str ->
String.length(id_str) <= @max_uuid_length &&
match?({:ok, _}, Ecto.UUID.cast(id_str)) &&
Map.has_key?(boolean_custom_fields_map, id_str)
end)
ids_to_load =
(visible_custom_field_ids ++ active_boolean_filter_ids)
|> Enum.uniq()
query = load_custom_field_values(query, ids_to_load)
query = load_custom_field_values(query, compute_ids_to_load(socket))
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
@ -984,6 +1004,13 @@ defmodule MvWeb.MemberLive.Index do
query =
apply_fee_type_filters(query, socket.assigns[:fee_type_filters], socket.assigns[:fee_types])
# 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)
# Use ALL custom fields for sorting (not just show_in_overview subset)
custom_fields_for_sort = socket.assigns.all_custom_fields
@ -1003,21 +1030,7 @@ defmodule MvWeb.MemberLive.Index do
# Custom field values are already filtered at the database level in load_custom_field_values/2
# No need for in-memory filtering anymore
# Apply cycle status filter if set
members =
apply_cycle_status_filter(
members,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
)
# Apply boolean custom field filters if set
members =
apply_boolean_custom_field_filters(
members,
socket.assigns.boolean_custom_field_filters,
socket.assigns.all_custom_fields
)
members = apply_in_memory_filters(members, socket)
# 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
@ -1037,6 +1050,55 @@ defmodule MvWeb.MemberLive.Index do
assign(socket, :members, members)
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
# 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
@ -1649,24 +1711,22 @@ defmodule MvWeb.MemberLive.Index do
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
defp maybe_update_date_filters(socket, _params), do: socket
# -------------------------------------------------------------
# Custom Field Value Helpers
# -------------------------------------------------------------
def get_custom_field_value(member, custom_field) do
case member.custom_field_values do
nil ->
nil
values when is_list(values) ->
Enum.find(values, fn cfv ->
cfv.custom_field_id == custom_field.id or
(match?(%{custom_field: %{id: _}}, cfv) && cfv.custom_field.id == custom_field.id)
end)
_ ->
nil
end
CustomFieldValueLookup.find_by_field(member, custom_field)
end
def get_boolean_custom_field_value(member, custom_field) do
@ -1725,29 +1785,12 @@ defmodule MvWeb.MemberLive.Index do
end
defp matches_filter?(member, custom_field_id_str, filter_value) do
case find_custom_field_value_by_id(member, custom_field_id_str) do
case CustomFieldValueLookup.find_by_id(member, custom_field_id_str) do
nil -> false
cfv -> extract_boolean_value(cfv.value) == filter_value
end
end
defp find_custom_field_value_by_id(member, custom_field_id_str) do
case member.custom_field_values do
nil ->
nil
values when is_list(values) ->
Enum.find(values, fn cfv ->
to_string(cfv.custom_field_id) == custom_field_id_str or
(match?(%{custom_field: %{id: _}}, cfv) &&
to_string(cfv.custom_field.id) == custom_field_id_str)
end)
_ ->
nil
end
end
def format_selected_member_emails(members, selected_members) do
members
|> Enum.filter(fn member ->