feat(member-live): sort the composite Member column by first or last name

This commit is contained in:
Simon 2026-07-16 09:39:07 +02:00
parent ca2aaa069c
commit f7005f395f
11 changed files with 443 additions and 34 deletions

View file

@ -172,7 +172,7 @@ defmodule MvWeb.MemberLive.Index do
socket
|> Layouts.assign_page_title(gettext("Members"))
|> assign(:query, "")
|> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_field, fn -> :last_name end)
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:cycle_status_filter, nil)
|> assign(:group_filters, %{})
@ -379,6 +379,22 @@ defmodule MvWeb.MemberLive.Index do
end
{new_field, new_order} = determine_new_sort(field, socket)
{:noreply, apply_sort_change(socket, new_field, new_order)}
end
# The composite "Member" column's sub-field menu (first name / last name) always
# sorts ascending by the chosen field: it switches the sort key rather than
# toggling direction (a click on the header itself toggles direction).
def handle_event("sort_composite", %{"field" => field_str}, socket) do
field = String.to_existing_atom(field_str)
{:noreply, apply_sort_change(socket, field, :asc)}
end
# Applies a resolved (field, order) sort: updates assigns and the sort-header
# components, reloads the keyset stream from page 1, scrolls to top, and syncs
# the URL. Shared by the header-click ("sort") and sub-field menu
# ("sort_composite") paths.
defp apply_sort_change(socket, new_field, new_order) do
old_field = socket.assigns.sort_field
socket =
@ -394,8 +410,8 @@ defmodule MvWeb.MemberLive.Index do
query_params =
build_query_params(
opts_for_query_params(socket, %{
sort_field: ExportPayload.sort_field(socket.assigns.sort_field),
sort_order: ExportPayload.sort_order(socket.assigns.sort_order)
sort_field: ExportPayload.sort_field(new_field),
sort_order: ExportPayload.sort_order(new_order)
})
)
|> maybe_add_field_selection(
@ -403,7 +419,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns[:fields_in_url?] || false
)
{:noreply, push_reload(socket, ~p"/members?#{query_params}")}
push_reload(socket, ~p"/members?#{query_params}")
end
# -----------------------------------------------------------------
@ -1488,9 +1504,11 @@ defmodule MvWeb.MemberLive.Index do
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]
# sort keys in OverviewQuery): the groups aggregate and the composite Address
# column. The composite Member column sorts by its real first/last-name
# sub-fields (chosen via its sub-field menu), so it needs no pseudo token here.
# Other computed fields (e.g. membership_fee_status) are not sortable.
@sortable_computed_fields [:groups, :address]
defp determine_field(default, sf) when is_binary(sf) do
sortable_strings = Enum.map(@sortable_computed_fields, &Atom.to_string/1)

View file

@ -170,17 +170,12 @@
<:col
:let={member}
:if={:name in @member_fields_visible and @compact_member}
sort_field={:name}
sort_field={if @sort_field in [:first_name, :last_name], do: @sort_field}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_name}
field={:name}
label={gettext("Name")}
<MvWeb.Components.MemberNameSortHeader.composite_name_sort_header
sort_field={@sort_field}
sort_order={@sort_order}
sort_hint={gettext("Click to sort by last name")}
/>
"""
}

View file

@ -254,12 +254,19 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
defp apply_sort(query, nil, _order, _custom_fields), do: Ash.Query.sort(query, id: :asc)
defp apply_sort(query, _field, nil, _custom_fields), do: Ash.Query.sort(query, id: :asc)
# Composite "Member" column: sort by last name then first name. The email that
# shares the cell is display-only and does not affect the sort key (§1.21).
defp apply_sort(query, field, order, _custom_fields) when field in [:name, "name"] do
# Composite "Member" column sub-fields (§1.21). Sorting by one name part uses
# the other name part (then id) as a stable tie-breaker; the email that shares
# the cell is display-only and never a sort key. Shared by the composite
# sub-field menu and the separate first/last-name columns.
defp apply_sort(query, field, order, _custom_fields) when field in [:last_name, "last_name"] do
Ash.Query.sort(query, [{:last_name, order}, {:first_name, order}, {:id, :asc}])
end
defp apply_sort(query, field, order, _custom_fields)
when field in [:first_name, "first_name"] do
Ash.Query.sort(query, [{:first_name, order}, {:last_name, order}, {:id, :asc}])
end
# Composite "Address" column: sort by city, then postal code, then street.
defp apply_sort(query, field, order, _custom_fields) when field in [:address, "address"] do
Ash.Query.sort(query, [