This commit is contained in:
parent
3322efcdf6
commit
5fd7c0e7f6
13 changed files with 583 additions and 258 deletions
|
|
@ -41,6 +41,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||
@boolean_filter_prefix Mv.Constants.boolean_filter_prefix()
|
||||
@group_filter_prefix "group_"
|
||||
|
||||
# Maximum number of boolean custom field filters allowed per request (DoS protection)
|
||||
@max_boolean_filters Mv.Constants.max_boolean_filters()
|
||||
|
|
@ -121,7 +122,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> assign_new(:sort_field, fn -> :first_name end)
|
||||
|> assign_new(:sort_order, fn -> :asc end)
|
||||
|> assign(:cycle_status_filter, nil)
|
||||
|> assign(:group_filter, nil)
|
||||
|> assign(:group_filters, %{})
|
||||
|> assign(:groups, groups)
|
||||
|> assign(:boolean_custom_field_filters, %{})
|
||||
|> assign(:selected_members, MapSet.new())
|
||||
|
|
@ -250,7 +251,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.sort_field,
|
||||
socket.assigns.sort_order,
|
||||
socket.assigns.cycle_status_filter,
|
||||
socket.assigns[:group_filter],
|
||||
socket.assigns[:group_filters],
|
||||
new_show_current,
|
||||
socket.assigns.boolean_custom_field_filters
|
||||
)
|
||||
|
|
@ -264,35 +265,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("group_filter_changed", %{"group_filter" => group_id_param}, socket) do
|
||||
group_filter = normalize_group_filter(group_id_param, socket.assigns.groups)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:group_filter, group_filter)
|
||||
|> load_members()
|
||||
|> update_selection_assigns()
|
||||
|
||||
query_params =
|
||||
build_query_params(
|
||||
socket.assigns.query,
|
||||
socket.assigns.sort_field,
|
||||
socket.assigns.sort_order,
|
||||
socket.assigns.cycle_status_filter,
|
||||
group_filter,
|
||||
socket.assigns.show_current_cycle,
|
||||
socket.assigns.boolean_custom_field_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
|
||||
|
||||
@impl true
|
||||
def handle_event("copy_emails", _params, socket) do
|
||||
selected_ids = socket.assigns.selected_members
|
||||
|
|
@ -390,7 +362,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
export_sort_field(socket.assigns.sort_field),
|
||||
export_sort_order(socket.assigns.sort_order),
|
||||
socket.assigns.cycle_status_filter,
|
||||
socket.assigns[:group_filter],
|
||||
socket.assigns[:group_filters],
|
||||
socket.assigns.show_current_cycle,
|
||||
socket.assigns.boolean_custom_field_filters
|
||||
)
|
||||
|
|
@ -416,7 +388,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.sort_field,
|
||||
socket.assigns.sort_order,
|
||||
socket.assigns.cycle_status_filter,
|
||||
socket.assigns[:group_filter],
|
||||
socket.assigns[:group_filters],
|
||||
socket.assigns.show_current_cycle,
|
||||
socket.assigns.boolean_custom_field_filters
|
||||
)
|
||||
|
|
@ -444,7 +416,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.sort_field,
|
||||
socket.assigns.sort_order,
|
||||
filter,
|
||||
socket.assigns[:group_filter],
|
||||
socket.assigns[:group_filters],
|
||||
socket.assigns.show_current_cycle,
|
||||
socket.assigns.boolean_custom_field_filters
|
||||
)
|
||||
|
|
@ -478,7 +450,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.sort_field,
|
||||
socket.assigns.sort_order,
|
||||
socket.assigns.cycle_status_filter,
|
||||
socket.assigns[:group_filter],
|
||||
socket.assigns[:group_filters],
|
||||
socket.assigns.show_current_cycle,
|
||||
updated_filters
|
||||
)
|
||||
|
|
@ -491,12 +463,55 @@ defmodule MvWeb.MemberLive.Index do
|
|||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||||
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()
|
||||
|> update_selection_assigns()
|
||||
|
||||
query_params =
|
||||
build_query_params(
|
||||
socket.assigns.query,
|
||||
socket.assigns.sort_field,
|
||||
socket.assigns.sort_order,
|
||||
socket.assigns.cycle_status_filter,
|
||||
group_filters,
|
||||
socket.assigns.show_current_cycle,
|
||||
socket.assigns.boolean_custom_field_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
|
||||
|
||||
@impl true
|
||||
def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
|
||||
handle_info({:reset_all_filters, cycle_status_filter, boolean_filters, %{}}, socket)
|
||||
end
|
||||
|
||||
def handle_info(
|
||||
{:reset_all_filters, cycle_status_filter, boolean_filters, group_filters},
|
||||
socket
|
||||
) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:cycle_status_filter, cycle_status_filter)
|
||||
|> assign(:group_filter, nil)
|
||||
|> assign(:group_filters, group_filters)
|
||||
|> assign(:boolean_custom_field_filters, boolean_filters)
|
||||
|> load_members()
|
||||
|> update_selection_assigns()
|
||||
|
|
@ -507,7 +522,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.sort_field,
|
||||
socket.assigns.sort_order,
|
||||
cycle_status_filter,
|
||||
socket.assigns[:group_filter],
|
||||
socket.assigns[:group_filters],
|
||||
socket.assigns.show_current_cycle,
|
||||
boolean_filters
|
||||
)
|
||||
|
|
@ -644,7 +659,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> maybe_update_search(params)
|
||||
|> maybe_update_sort(params)
|
||||
|> maybe_update_cycle_status_filter(params)
|
||||
|> maybe_update_group_filter(params)
|
||||
|> maybe_update_group_filters(params)
|
||||
|> maybe_update_boolean_filters(params)
|
||||
|> maybe_update_show_current_cycle(params)
|
||||
|> assign(:fields_in_url?, fields_in_url?)
|
||||
|
|
@ -678,7 +693,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.sort_field,
|
||||
socket.assigns.sort_order,
|
||||
socket.assigns.cycle_status_filter,
|
||||
socket.assigns[:group_filter],
|
||||
socket.assigns[:group_filters],
|
||||
socket.assigns.show_current_cycle,
|
||||
socket.assigns.boolean_custom_field_filters,
|
||||
socket.assigns.user_field_selection,
|
||||
|
|
@ -772,7 +787,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.sort_field,
|
||||
socket.assigns.sort_order,
|
||||
socket.assigns.cycle_status_filter,
|
||||
socket.assigns[:group_filter],
|
||||
socket.assigns[:group_filters],
|
||||
socket.assigns.show_current_cycle,
|
||||
socket.assigns.boolean_custom_field_filters
|
||||
)
|
||||
|
|
@ -791,7 +806,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
sort_field,
|
||||
sort_order,
|
||||
cycle_status_filter,
|
||||
group_filter,
|
||||
group_filters,
|
||||
show_current_cycle,
|
||||
boolean_filters
|
||||
) do
|
||||
|
|
@ -823,11 +838,10 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
|
||||
base_params =
|
||||
if group_filter && group_filter != "" do
|
||||
Map.put(base_params, "group_filter", to_string(group_filter))
|
||||
else
|
||||
base_params
|
||||
end
|
||||
Enum.reduce(group_filters, base_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)
|
||||
|
||||
base_params =
|
||||
if show_current_cycle do
|
||||
|
|
@ -884,7 +898,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
query = apply_search_filter(query, search_query)
|
||||
|
||||
query = apply_group_filter(query, socket.assigns[:group_filter])
|
||||
query = apply_group_filters(query, socket.assigns[:group_filters], socket.assigns[:groups])
|
||||
|
||||
# Use ALL custom fields for sorting (not just show_in_overview subset)
|
||||
custom_fields_for_sort = socket.assigns.all_custom_fields
|
||||
|
|
@ -963,13 +977,50 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
end
|
||||
|
||||
defp apply_group_filter(query, nil), do: query
|
||||
defp apply_group_filter(query, ""), do: query
|
||||
defp apply_group_filters(query, group_filters, _groups) when group_filters == %{}, do: query
|
||||
|
||||
defp apply_group_filter(query, group_id) when is_binary(group_id) do
|
||||
Ash.Query.filter(query, expr(exists(groups, id == ^group_id)))
|
||||
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
|
||||
|
||||
defp apply_cycle_status_filter(members, nil, _show_current), do: members
|
||||
|
||||
defp apply_cycle_status_filter(members, status, show_current)
|
||||
|
|
@ -1097,17 +1148,15 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
|
||||
defp sort_members_in_memory(members, field, order, custom_fields) do
|
||||
cond do
|
||||
field in [:groups, "groups"] ->
|
||||
sort_members_by_groups(members, order)
|
||||
if field in [:groups, "groups"] do
|
||||
sort_members_by_groups(members, order)
|
||||
else
|
||||
custom_field_id_str = extract_custom_field_id(field)
|
||||
|
||||
true ->
|
||||
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
|
||||
case custom_field_id_str do
|
||||
nil -> members
|
||||
id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -1223,8 +1272,12 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
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
|
||||
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
|
||||
|
|
@ -1257,31 +1310,61 @@ defmodule MvWeb.MemberLive.Index do
|
|||
defp maybe_update_cycle_status_filter(socket, _params),
|
||||
do: assign(socket, :cycle_status_filter, nil)
|
||||
|
||||
defp maybe_update_group_filter(socket, %{"group_filter" => group_id_param}) do
|
||||
group_filter = normalize_group_filter(group_id_param, socket.assigns.groups)
|
||||
assign(socket, :group_filter, group_filter)
|
||||
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)
|
||||
|
||||
assign(socket, :group_filters, filters)
|
||||
end
|
||||
|
||||
defp maybe_update_group_filter(socket, _params), do: socket
|
||||
defp maybe_update_group_filters(socket, _), do: socket
|
||||
|
||||
defp normalize_group_filter("", _groups), do: nil
|
||||
defp normalize_group_filter(nil, _groups), do: nil
|
||||
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
|
||||
|
||||
defp normalize_group_filter(group_id_param, groups) when is_binary(group_id_param) do
|
||||
case Ecto.UUID.cast(group_id_param) do
|
||||
{:ok, _uuid} ->
|
||||
if Enum.any?(groups, fn g -> to_string(g.id) == group_id_param end) do
|
||||
group_id_param
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
:error ->
|
||||
nil
|
||||
if valid_id? do
|
||||
case parse_group_filter_value(value_str) do
|
||||
nil -> acc
|
||||
value -> Map.put(acc, group_id_str, value)
|
||||
end
|
||||
else
|
||||
acc
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_group_filter(_, _), do: nil
|
||||
# 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 parse_group_filter_value("in"), do: :in
|
||||
defp parse_group_filter_value("not_in"), do: :not_in
|
||||
|
||||
defp parse_group_filter_value(val) when is_binary(val) do
|
||||
parse_group_filter_value(String.trim(val))
|
||||
end
|
||||
|
||||
defp parse_group_filter_value(_), do: nil
|
||||
|
||||
defp determine_cycle_status_filter("paid"), do: :paid
|
||||
defp determine_cycle_status_filter("unpaid"), do: :unpaid
|
||||
|
|
|
|||
|
|
@ -52,26 +52,12 @@
|
|||
query={@query}
|
||||
placeholder={gettext("Search...")}
|
||||
/>
|
||||
<form id="group-filter-form" phx-change="group_filter_changed" class="contents">
|
||||
<select
|
||||
name="group_filter"
|
||||
class="select select-bordered select-sm max-w-xs"
|
||||
aria-label={gettext("Filter by group")}
|
||||
>
|
||||
<option value="" selected={@group_filter == nil}>
|
||||
{gettext("All groups")}
|
||||
</option>
|
||||
<%= for group <- @groups do %>
|
||||
<option value={group.id} selected={@group_filter == to_string(group.id)}>
|
||||
{group.name}
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
</form>
|
||||
<.live_component
|
||||
module={MvWeb.Components.MemberFilterComponent}
|
||||
id="member-filter"
|
||||
cycle_status_filter={@cycle_status_filter}
|
||||
groups={@groups}
|
||||
group_filters={@group_filters}
|
||||
boolean_custom_fields={@boolean_custom_fields}
|
||||
boolean_filters={@boolean_custom_field_filters}
|
||||
member_count={length(@members)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue