feat: add groups to member overview
This commit is contained in:
parent
82e908a7e4
commit
dce4b2cf33
6 changed files with 459 additions and 13 deletions
|
|
@ -85,6 +85,12 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> Enum.filter(&(&1.value_type == :boolean))
|
|> Enum.filter(&(&1.value_type == :boolean))
|
||||||
|> Enum.sort_by(& &1.name, :asc)
|
|> 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 settings once to avoid N+1 queries
|
# Load settings once to avoid N+1 queries
|
||||||
settings =
|
settings =
|
||||||
case Membership.get_settings() do
|
case Membership.get_settings() do
|
||||||
|
|
@ -115,6 +121,8 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> assign_new(:sort_field, fn -> :first_name end)
|
|> assign_new(:sort_field, fn -> :first_name end)
|
||||||
|> assign_new(:sort_order, fn -> :asc end)
|
|> assign_new(:sort_order, fn -> :asc end)
|
||||||
|> assign(:cycle_status_filter, nil)
|
|> assign(:cycle_status_filter, nil)
|
||||||
|
|> assign(:group_filter, nil)
|
||||||
|
|> assign(:groups, groups)
|
||||||
|> assign(:boolean_custom_field_filters, %{})
|
|> assign(:boolean_custom_field_filters, %{})
|
||||||
|> assign(:selected_members, MapSet.new())
|
|> assign(:selected_members, MapSet.new())
|
||||||
|> assign(:settings, settings)
|
|> assign(:settings, settings)
|
||||||
|
|
@ -242,6 +250,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.sort_field,
|
socket.assigns.sort_field,
|
||||||
socket.assigns.sort_order,
|
socket.assigns.sort_order,
|
||||||
socket.assigns.cycle_status_filter,
|
socket.assigns.cycle_status_filter,
|
||||||
|
socket.assigns[:group_filter],
|
||||||
new_show_current,
|
new_show_current,
|
||||||
socket.assigns.boolean_custom_field_filters
|
socket.assigns.boolean_custom_field_filters
|
||||||
)
|
)
|
||||||
|
|
@ -255,6 +264,35 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||||||
end
|
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
|
@impl true
|
||||||
def handle_event("copy_emails", _params, socket) do
|
def handle_event("copy_emails", _params, socket) do
|
||||||
selected_ids = socket.assigns.selected_members
|
selected_ids = socket.assigns.selected_members
|
||||||
|
|
@ -352,6 +390,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
export_sort_field(socket.assigns.sort_field),
|
export_sort_field(socket.assigns.sort_field),
|
||||||
export_sort_order(socket.assigns.sort_order),
|
export_sort_order(socket.assigns.sort_order),
|
||||||
socket.assigns.cycle_status_filter,
|
socket.assigns.cycle_status_filter,
|
||||||
|
socket.assigns[:group_filter],
|
||||||
socket.assigns.show_current_cycle,
|
socket.assigns.show_current_cycle,
|
||||||
socket.assigns.boolean_custom_field_filters
|
socket.assigns.boolean_custom_field_filters
|
||||||
)
|
)
|
||||||
|
|
@ -377,6 +416,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.sort_field,
|
socket.assigns.sort_field,
|
||||||
socket.assigns.sort_order,
|
socket.assigns.sort_order,
|
||||||
socket.assigns.cycle_status_filter,
|
socket.assigns.cycle_status_filter,
|
||||||
|
socket.assigns[:group_filter],
|
||||||
socket.assigns.show_current_cycle,
|
socket.assigns.show_current_cycle,
|
||||||
socket.assigns.boolean_custom_field_filters
|
socket.assigns.boolean_custom_field_filters
|
||||||
)
|
)
|
||||||
|
|
@ -404,6 +444,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.sort_field,
|
socket.assigns.sort_field,
|
||||||
socket.assigns.sort_order,
|
socket.assigns.sort_order,
|
||||||
filter,
|
filter,
|
||||||
|
socket.assigns[:group_filter],
|
||||||
socket.assigns.show_current_cycle,
|
socket.assigns.show_current_cycle,
|
||||||
socket.assigns.boolean_custom_field_filters
|
socket.assigns.boolean_custom_field_filters
|
||||||
)
|
)
|
||||||
|
|
@ -437,6 +478,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.sort_field,
|
socket.assigns.sort_field,
|
||||||
socket.assigns.sort_order,
|
socket.assigns.sort_order,
|
||||||
socket.assigns.cycle_status_filter,
|
socket.assigns.cycle_status_filter,
|
||||||
|
socket.assigns[:group_filter],
|
||||||
socket.assigns.show_current_cycle,
|
socket.assigns.show_current_cycle,
|
||||||
updated_filters
|
updated_filters
|
||||||
)
|
)
|
||||||
|
|
@ -454,6 +496,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:cycle_status_filter, cycle_status_filter)
|
|> assign(:cycle_status_filter, cycle_status_filter)
|
||||||
|
|> assign(:group_filter, nil)
|
||||||
|> assign(:boolean_custom_field_filters, boolean_filters)
|
|> assign(:boolean_custom_field_filters, boolean_filters)
|
||||||
|> load_members()
|
|> load_members()
|
||||||
|> update_selection_assigns()
|
|> update_selection_assigns()
|
||||||
|
|
@ -464,6 +507,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.sort_field,
|
socket.assigns.sort_field,
|
||||||
socket.assigns.sort_order,
|
socket.assigns.sort_order,
|
||||||
cycle_status_filter,
|
cycle_status_filter,
|
||||||
|
socket.assigns[:group_filter],
|
||||||
socket.assigns.show_current_cycle,
|
socket.assigns.show_current_cycle,
|
||||||
boolean_filters
|
boolean_filters
|
||||||
)
|
)
|
||||||
|
|
@ -600,6 +644,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> maybe_update_search(params)
|
|> maybe_update_search(params)
|
||||||
|> maybe_update_sort(params)
|
|> maybe_update_sort(params)
|
||||||
|> maybe_update_cycle_status_filter(params)
|
|> maybe_update_cycle_status_filter(params)
|
||||||
|
|> maybe_update_group_filter(params)
|
||||||
|> maybe_update_boolean_filters(params)
|
|> maybe_update_boolean_filters(params)
|
||||||
|> maybe_update_show_current_cycle(params)
|
|> maybe_update_show_current_cycle(params)
|
||||||
|> assign(:fields_in_url?, fields_in_url?)
|
|> assign(:fields_in_url?, fields_in_url?)
|
||||||
|
|
@ -633,6 +678,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.sort_field,
|
socket.assigns.sort_field,
|
||||||
socket.assigns.sort_order,
|
socket.assigns.sort_order,
|
||||||
socket.assigns.cycle_status_filter,
|
socket.assigns.cycle_status_filter,
|
||||||
|
socket.assigns[:group_filter],
|
||||||
socket.assigns.show_current_cycle,
|
socket.assigns.show_current_cycle,
|
||||||
socket.assigns.boolean_custom_field_filters,
|
socket.assigns.boolean_custom_field_filters,
|
||||||
socket.assigns.user_field_selection,
|
socket.assigns.user_field_selection,
|
||||||
|
|
@ -726,6 +772,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.sort_field,
|
socket.assigns.sort_field,
|
||||||
socket.assigns.sort_order,
|
socket.assigns.sort_order,
|
||||||
socket.assigns.cycle_status_filter,
|
socket.assigns.cycle_status_filter,
|
||||||
|
socket.assigns[:group_filter],
|
||||||
socket.assigns.show_current_cycle,
|
socket.assigns.show_current_cycle,
|
||||||
socket.assigns.boolean_custom_field_filters
|
socket.assigns.boolean_custom_field_filters
|
||||||
)
|
)
|
||||||
|
|
@ -744,6 +791,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
sort_field,
|
sort_field,
|
||||||
sort_order,
|
sort_order,
|
||||||
cycle_status_filter,
|
cycle_status_filter,
|
||||||
|
group_filter,
|
||||||
show_current_cycle,
|
show_current_cycle,
|
||||||
boolean_filters
|
boolean_filters
|
||||||
) do
|
) do
|
||||||
|
|
@ -774,6 +822,13 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
:unpaid -> Map.put(base_params, "cycle_status_filter", "unpaid")
|
:unpaid -> Map.put(base_params, "cycle_status_filter", "unpaid")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
base_params =
|
||||||
|
if group_filter && group_filter != "" do
|
||||||
|
Map.put(base_params, "group_filter", to_string(group_filter))
|
||||||
|
else
|
||||||
|
base_params
|
||||||
|
end
|
||||||
|
|
||||||
base_params =
|
base_params =
|
||||||
if show_current_cycle do
|
if show_current_cycle do
|
||||||
Map.put(base_params, "show_current_cycle", "true")
|
Map.put(base_params, "show_current_cycle", "true")
|
||||||
|
|
@ -823,8 +878,14 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|
|
||||||
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
|
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
|
||||||
|
|
||||||
|
# Load groups for each member (id, name, slug only)
|
||||||
|
query =
|
||||||
|
Ash.Query.load(query, groups: [:id, :name, :slug])
|
||||||
|
|
||||||
query = apply_search_filter(query, search_query)
|
query = apply_search_filter(query, search_query)
|
||||||
|
|
||||||
|
query = apply_group_filter(query, socket.assigns[:group_filter])
|
||||||
|
|
||||||
# Use ALL custom fields for sorting (not just show_in_overview subset)
|
# Use ALL custom fields for sorting (not just show_in_overview subset)
|
||||||
custom_fields_for_sort = socket.assigns.all_custom_fields
|
custom_fields_for_sort = socket.assigns.all_custom_fields
|
||||||
|
|
||||||
|
|
@ -860,7 +921,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.all_custom_fields
|
socket.assigns.all_custom_fields
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sort in memory if needed (custom fields only; computed fields are blocked)
|
# Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked)
|
||||||
members =
|
members =
|
||||||
if sort_after_load and
|
if sort_after_load and
|
||||||
socket.assigns.sort_field not in FieldVisibility.computed_member_fields() do
|
socket.assigns.sort_field not in FieldVisibility.computed_member_fields() do
|
||||||
|
|
@ -902,6 +963,13 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp apply_group_filter(query, nil), do: query
|
||||||
|
defp apply_group_filter(query, ""), 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)))
|
||||||
|
end
|
||||||
|
|
||||||
defp apply_cycle_status_filter(members, nil, _show_current), do: members
|
defp apply_cycle_status_filter(members, nil, _show_current), do: members
|
||||||
|
|
||||||
defp apply_cycle_status_filter(members, status, show_current)
|
defp apply_cycle_status_filter(members, status, show_current)
|
||||||
|
|
@ -937,6 +1005,10 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|
|
||||||
defp apply_sort_to_query(query, field, order) do
|
defp apply_sort_to_query(query, field, order) do
|
||||||
cond do
|
cond do
|
||||||
|
# Groups sort -> after load (in memory)
|
||||||
|
field in [:groups, "groups"] ->
|
||||||
|
{query, true}
|
||||||
|
|
||||||
# Custom field sort -> after load
|
# Custom field sort -> after load
|
||||||
custom_field_sort?(field) ->
|
custom_field_sort?(field) ->
|
||||||
{query, true}
|
{query, true}
|
||||||
|
|
@ -976,10 +1048,11 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
defp valid_sort_field_db_or_custom?(field) when is_atom(field) do
|
defp valid_sort_field_db_or_custom?(field) when is_atom(field) do
|
||||||
non_sortable_fields = [:notes]
|
non_sortable_fields = [:notes]
|
||||||
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
|
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
|
||||||
field in valid_fields or custom_field_sort?(field)
|
field in valid_fields or custom_field_sort?(field) or field == :groups
|
||||||
end
|
end
|
||||||
|
|
||||||
defp valid_sort_field_db_or_custom?(field) when is_binary(field) do
|
defp valid_sort_field_db_or_custom?(field) when is_binary(field) do
|
||||||
|
field == "groups" or
|
||||||
custom_field_sort?(field) or
|
custom_field_sort?(field) or
|
||||||
((atom = safe_member_field_atom_only(field)) != nil and valid_sort_field_db_or_custom?(atom))
|
((atom = safe_member_field_atom_only(field)) != nil and valid_sort_field_db_or_custom?(atom))
|
||||||
end
|
end
|
||||||
|
|
@ -1024,6 +1097,11 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp sort_members_in_memory(members, field, order, custom_fields) do
|
defp sort_members_in_memory(members, field, order, custom_fields) do
|
||||||
|
cond do
|
||||||
|
field in [:groups, "groups"] ->
|
||||||
|
sort_members_by_groups(members, order)
|
||||||
|
|
||||||
|
true ->
|
||||||
custom_field_id_str = extract_custom_field_id(field)
|
custom_field_id_str = extract_custom_field_id(field)
|
||||||
|
|
||||||
case custom_field_id_str do
|
case custom_field_id_str do
|
||||||
|
|
@ -1031,6 +1109,24 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields)
|
id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sort_members_by_groups(members, order) do
|
||||||
|
# Members with groups first, then by first group name alphabetically
|
||||||
|
first_group_name = fn member ->
|
||||||
|
groups = member.groups || []
|
||||||
|
names = Enum.map(groups, & &1.name) |> Enum.sort()
|
||||||
|
List.first(names)
|
||||||
|
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
|
defp sort_members_by_custom_field(members, id_str, order, custom_fields) do
|
||||||
custom_field = find_custom_field_by_id(custom_fields, id_str)
|
custom_field = find_custom_field_by_id(custom_fields, id_str)
|
||||||
|
|
@ -1126,9 +1222,10 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
defp determine_field(default, _), do: default
|
defp determine_field(default, _), do: default
|
||||||
|
|
||||||
defp determine_field_after_computed_check(default, sf) when is_binary(sf) do
|
defp determine_field_after_computed_check(default, sf) when is_binary(sf) do
|
||||||
if custom_field_sort?(sf) do
|
cond do
|
||||||
if valid_sort_field?(sf), do: sf, else: default
|
sf == "groups" -> :groups
|
||||||
else
|
custom_field_sort?(sf) -> if valid_sort_field?(sf), do: sf, else: default
|
||||||
|
true ->
|
||||||
atom = safe_member_field_atom_only(sf)
|
atom = safe_member_field_atom_only(sf)
|
||||||
if atom != nil and valid_sort_field?(atom), do: atom, else: default
|
if atom != nil and valid_sort_field?(atom), do: atom, else: default
|
||||||
end
|
end
|
||||||
|
|
@ -1160,6 +1257,32 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
defp maybe_update_cycle_status_filter(socket, _params),
|
defp maybe_update_cycle_status_filter(socket, _params),
|
||||||
do: assign(socket, :cycle_status_filter, nil)
|
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)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_update_group_filter(socket, _params), do: socket
|
||||||
|
|
||||||
|
defp normalize_group_filter("", _groups), do: nil
|
||||||
|
defp normalize_group_filter(nil, _groups), do: nil
|
||||||
|
|
||||||
|
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
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_group_filter(_, _), do: nil
|
||||||
|
|
||||||
defp determine_cycle_status_filter("paid"), do: :paid
|
defp determine_cycle_status_filter("paid"), do: :paid
|
||||||
defp determine_cycle_status_filter("unpaid"), do: :unpaid
|
defp determine_cycle_status_filter("unpaid"), do: :unpaid
|
||||||
defp determine_cycle_status_filter(_), do: nil
|
defp determine_cycle_status_filter(_), do: nil
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,22 @@
|
||||||
query={@query}
|
query={@query}
|
||||||
placeholder={gettext("Search...")}
|
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
|
<.live_component
|
||||||
module={MvWeb.Components.MemberFilterComponent}
|
module={MvWeb.Components.MemberFilterComponent}
|
||||||
id="member-filter"
|
id="member-filter"
|
||||||
|
|
@ -310,6 +326,34 @@
|
||||||
<span class="badge badge-ghost">{gettext("No cycle")}</span>
|
<span class="badge badge-ghost">{gettext("No cycle")}</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</:col>
|
</:col>
|
||||||
|
<:col
|
||||||
|
:let={member}
|
||||||
|
label={
|
||||||
|
~H"""
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
|
id={:sort_groups}
|
||||||
|
field={:groups}
|
||||||
|
label={gettext("Groups")}
|
||||||
|
sort_field={@sort_field}
|
||||||
|
sort_order={@sort_order}
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<%= for group <- (member.groups || []) do %>
|
||||||
|
<span
|
||||||
|
class="badge badge-outline badge-primary"
|
||||||
|
role="status"
|
||||||
|
aria-label={gettext("Member of group %{name}", name: group.name)}
|
||||||
|
>
|
||||||
|
{group.name}
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<%= if (member.groups || []) == [] do %>
|
||||||
|
<span class="text-base-content/50">—</span>
|
||||||
|
<% end %>
|
||||||
|
</:col>
|
||||||
<:action :let={member}>
|
<:action :let={member}>
|
||||||
<div class="sr-only">
|
<div class="sr-only">
|
||||||
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
|
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
|
||||||
|
|
|
||||||
2
mix.lock
2
mix.lock
|
|
@ -72,7 +72,7 @@
|
||||||
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
|
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
|
||||||
"sourceror": {:hex, :sourceror, "1.10.1", "325753ed460fe9fa34ebb4deda76d57b2e1507dcd78a5eb9e1c41bfb78b7cdfe", [:mix], [], "hexpm", "288f3079d93865cd1e3e20df5b884ef2cb440e0e03e8ae393624ee8a770ba588"},
|
"sourceror": {:hex, :sourceror, "1.10.1", "325753ed460fe9fa34ebb4deda76d57b2e1507dcd78a5eb9e1c41bfb78b7cdfe", [:mix], [], "hexpm", "288f3079d93865cd1e3e20df5b884ef2cb440e0e03e8ae393624ee8a770ba588"},
|
||||||
"spark": {:hex, :spark, "2.4.0", "f93d3ae6b5f3004e956d52f359fa40670366685447631bc7c058f4fbf250ebf3", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "4e5185f5737cd987bb9ef377ae3462a55b8312f5007c2bc4ad6e850d14ac0111"},
|
"spark": {:hex, :spark, "2.4.0", "f93d3ae6b5f3004e956d52f359fa40670366685447631bc7c058f4fbf250ebf3", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "4e5185f5737cd987bb9ef377ae3462a55b8312f5007c2bc4ad6e850d14ac0111"},
|
||||||
"spitfire": {:hex, :spitfire, "0.3.3", "be195b27648f21454932bf46014455cdbce4fca55fef1f0e41d36076c47b6c4a", [], [], "hexpm", "5dc51c3b61a1d98cdcac1c130f0a374d22d51beed982df90834bdd616356e1fa"},
|
"spitfire": {:hex, :spitfire, "0.3.3", "be195b27648f21454932bf46014455cdbce4fca55fef1f0e41d36076c47b6c4a", [:mix], [], "hexpm", "5dc51c3b61a1d98cdcac1c130f0a374d22d51beed982df90834bdd616356e1fa"},
|
||||||
"splode": {:hex, :splode, "0.3.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"},
|
"splode": {:hex, :splode, "0.3.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"},
|
||||||
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
|
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
|
||||||
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
|
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
|
||||||
|
|
|
||||||
97
test/mv_web/member_live/index_groups_display_test.exs
Normal file
97
test/mv_web/member_live/index_groups_display_test.exs
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexGroupsDisplayTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for displaying groups in the member overview.
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- Group badges are displayed for members in groups
|
||||||
|
- Multiple badges for members in multiple groups
|
||||||
|
- No badge for members without groups
|
||||||
|
- Badge shows group name correctly
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
alias Mv.Membership.{Group, MemberGroup}
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, member1} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, member2} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, member3} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Charlie", last_name: "Clark", email: "charlie@example.com"},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, group1} =
|
||||||
|
Group
|
||||||
|
|> Ash.Changeset.for_create(:create, %{name: "Board Members"})
|
||||||
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
|
{:ok, group2} =
|
||||||
|
Group
|
||||||
|
|> Ash.Changeset.for_create(:create, %{name: "Active Members"})
|
||||||
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
|
{:ok, _mg1} =
|
||||||
|
MemberGroup
|
||||||
|
|> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group1.id})
|
||||||
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
|
{:ok, _mg2} =
|
||||||
|
MemberGroup
|
||||||
|
|> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group2.id})
|
||||||
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
|
{:ok, _mg3} =
|
||||||
|
MemberGroup
|
||||||
|
|> Ash.Changeset.for_create(:create, %{member_id: member2.id, group_id: group1.id})
|
||||||
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
|
%{member1: member1, member2: member2, member3: member3, group1: group1, group2: group2}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "displays group badges for members in groups", %{conn: conn, group1: group1, group2: group2} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, _view, html} = live(conn, "/members")
|
||||||
|
assert html =~ group1.name
|
||||||
|
assert html =~ group2.name
|
||||||
|
end
|
||||||
|
|
||||||
|
test "displays multiple badges for member in multiple groups", %{
|
||||||
|
conn: conn,
|
||||||
|
member1: member1,
|
||||||
|
group1: group1,
|
||||||
|
group2: group2
|
||||||
|
} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, _view, html} = live(conn, "/members")
|
||||||
|
assert html =~ member1.first_name
|
||||||
|
assert html =~ group1.name
|
||||||
|
assert html =~ group2.name
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows placeholder for members without groups", %{conn: conn, member3: member3} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, _view, html} = live(conn, "/members")
|
||||||
|
assert html =~ member3.first_name
|
||||||
|
end
|
||||||
|
|
||||||
|
test "displays group name correctly in badge", %{conn: conn, group1: group1} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, _view, html} = live(conn, "/members")
|
||||||
|
assert html =~ group1.name
|
||||||
|
end
|
||||||
|
end
|
||||||
113
test/mv_web/member_live/index_groups_filter_test.exs
Normal file
113
test/mv_web/member_live/index_groups_filter_test.exs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for filtering members by group in the member overview.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
alias Mv.Membership.{Group, MemberGroup}
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, member1} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, member2} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, member3} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Charlie", last_name: "Clark", email: "charlie@example.com"},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, group1} =
|
||||||
|
Group
|
||||||
|
|> Ash.Changeset.for_create(:create, %{name: "Board Members"})
|
||||||
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
|
{:ok, group2} =
|
||||||
|
Group
|
||||||
|
|> Ash.Changeset.for_create(:create, %{name: "Active Members"})
|
||||||
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
|
{:ok, _mg1} =
|
||||||
|
MemberGroup
|
||||||
|
|> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group1.id})
|
||||||
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
|
{:ok, _mg2} =
|
||||||
|
MemberGroup
|
||||||
|
|> Ash.Changeset.for_create(:create, %{member_id: member2.id, group_id: group2.id})
|
||||||
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
|
%{member1: member1, member2: member2, member3: member3, group1: group1, group2: group2}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "filter 'All groups' shows all members", %{conn: conn, member1: m1, member2: m2, member3: m3} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, _view, html} = live(conn, "/members")
|
||||||
|
assert html =~ m1.first_name
|
||||||
|
assert html =~ m2.first_name
|
||||||
|
assert html =~ m3.first_name
|
||||||
|
end
|
||||||
|
|
||||||
|
test "filter by specific group shows only members in that group", %{
|
||||||
|
conn: conn,
|
||||||
|
member1: m1,
|
||||||
|
member2: m2,
|
||||||
|
member3: m3,
|
||||||
|
group1: group1
|
||||||
|
} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element("#group-filter-form")
|
||||||
|
|> render_change(%{"group_filter" => group1.id})
|
||||||
|
|
||||||
|
html = render(view)
|
||||||
|
assert html =~ m1.first_name
|
||||||
|
refute html =~ m2.first_name
|
||||||
|
refute html =~ m3.first_name
|
||||||
|
end
|
||||||
|
|
||||||
|
test "filter persists in URL parameters", %{conn: conn, group1: group1, member1: m1} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element("#group-filter-form")
|
||||||
|
|> render_change(%{"group_filter" => group1.id})
|
||||||
|
|
||||||
|
# Verify filter is applied
|
||||||
|
html = render(view)
|
||||||
|
assert html =~ m1.first_name
|
||||||
|
|
||||||
|
# Verify visiting with group_filter in URL shows same filtered list
|
||||||
|
{:ok, _view2, html2} = live(conn, "/members?group_filter=#{group1.id}")
|
||||||
|
assert html2 =~ m1.first_name
|
||||||
|
end
|
||||||
|
|
||||||
|
test "filter is restored from URL on load", %{
|
||||||
|
conn: conn,
|
||||||
|
member1: m1,
|
||||||
|
member2: m2,
|
||||||
|
member3: m3,
|
||||||
|
group1: group1
|
||||||
|
} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, _view, html} = live(conn, "/members?group_filter=#{group1.id}")
|
||||||
|
assert html =~ m1.first_name
|
||||||
|
refute html =~ m2.first_name
|
||||||
|
refute html =~ m3.first_name
|
||||||
|
end
|
||||||
|
end
|
||||||
69
test/mv_web/member_live/index_groups_sorting_test.exs
Normal file
69
test/mv_web/member_live/index_groups_sorting_test.exs
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexGroupsSortingTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for sorting by groups in the member overview.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
alias Mv.Membership.{Group, MemberGroup}
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, member1} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, member2} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, member4} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "David", last_name: "Davis", email: "david@example.com"},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, group_a} =
|
||||||
|
Group
|
||||||
|
|> Ash.Changeset.for_create(:create, %{name: "A Group"})
|
||||||
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
|
{:ok, group_b} =
|
||||||
|
Group
|
||||||
|
|> Ash.Changeset.for_create(:create, %{name: "B Group"})
|
||||||
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
|
{:ok, _mg1} =
|
||||||
|
MemberGroup
|
||||||
|
|> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group_a.id})
|
||||||
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
|
{:ok, _mg2} =
|
||||||
|
MemberGroup
|
||||||
|
|> Ash.Changeset.for_create(:create, %{member_id: member2.id, group_id: group_b.id})
|
||||||
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
|
%{member1: member1, member2: member2, member4: member4, group_a: group_a, group_b: group_b}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sorts by group name ascending", %{conn: conn, group_a: group_a} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element("[data-testid='groups']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
# Sort was applied: button shows ascending state and group names still visible
|
||||||
|
assert has_element?(view, "[data-testid='groups']")
|
||||||
|
html = render(view)
|
||||||
|
assert html =~ group_a.name
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
Loading…
Add table
Add a link
Reference in a new issue