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

@ -0,0 +1,142 @@
defmodule MvWeb.Components.MemberNameSortHeader do
@moduledoc """
Header for the composite "Member" overview column (§1.21 / U3).
The column shows the full name (and, when folded in, the email), so a plain
"sort by X" is ambiguous. This header keeps the familiar one-click sort of the
other columns a click toggles the direction of the currently active sub-field
(last name by default) and adds a small sub-field menu (the "" button) to
switch between sorting by last name and first name. The active sub-field is
shown next to the direction chevron only while this column is the active sort,
so the header stays uncluttered otherwise but is never ambiguous when it does
sort.
The sub-field menu is rendered as a native Popover (top layer) so it escapes
the overview table's overflow clipping; the `SubfieldMenu` JS hook toggles and
positions it. Menu items emit `sort_composite` (always ascending by the chosen
field); the header click emits `sort` (toggles direction of the active field).
"""
use MvWeb, :html
@subfields [:first_name, :last_name]
attr :sort_field, :any, required: true, doc: "current sort field (atom or string)"
attr :sort_order, :atom, required: true, doc: ":asc or :desc"
def composite_name_sort_header(assigns) do
active = active_subfield(assigns.sort_field)
assigns =
assigns
|> assign(:active, active)
|> assign(:subfields, @subfields)
# A click on the header sorts by the active sub-field (toggling direction);
# when this column is not the active sort it starts from last name.
|> assign(:click_field, active || :last_name)
~H"""
<div class="flex items-center gap-1">
<button
id="sort-btn-member"
type="button"
phx-click="sort"
phx-value-field={@click_field}
aria-describedby="sort-tooltip-member"
phx-hook="PopoverTooltip"
data-tooltip-id="sort-tooltip-member"
class="link link-hover no-underline flex items-center gap-1 text-left text-current select-none"
data-testid="name"
>
{gettext("Member")}
<span :if={@active} class="opacity-70 font-normal">
· {subfield_label(@active)}
</span>
<.icon
:if={@active && @sort_order == :asc}
name="hero-chevron-up"
class="sort-icon"
/>
<.icon
:if={@active && @sort_order == :desc}
name="hero-chevron-down"
class="sort-icon"
/>
<.icon :if={!@active} name="hero-chevron-up-down" class="sort-icon opacity-40" />
</button>
<div id="sort-tooltip-member" popover="manual" role="tooltip" class="popover-tooltip">
{tooltip_text(@active)}
</div>
<button
id="member-subfield-btn"
type="button"
style="anchor-name: --member-sort-anchor"
aria-label={gettext("Choose sort field")}
aria-describedby="member-subfield-tooltip"
aria-haspopup="menu"
aria-expanded="false"
phx-hook="SubfieldMenu"
data-menu-id="member-subfield-menu"
data-tooltip-id="member-subfield-tooltip"
class="btn btn-ghost btn-xs btn-square"
data-testid="member-sort-subfield"
>
<.icon name="hero-ellipsis-vertical" class="size-4" />
</button>
<div
id="member-subfield-tooltip"
popover="manual"
role="tooltip"
style="position-anchor: --member-sort-anchor; position-area: bottom span-left; inset: auto"
class="popover-tooltip"
>
{gettext("Choose sort field")}
</div>
<ul
id="member-subfield-menu"
popover="manual"
role="menu"
aria-label={gettext("Sort members by")}
style="position-anchor: --member-sort-anchor"
class="popover-menu w-44 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
<li role="none" class="mb-1 px-2">
<span class="font-semibold">{gettext("Sort by")}</span>
</li>
<li role="separator" class="divider my-1"></li>
<li :for={field <- @subfields} role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@active == field)}
phx-click="sort_composite"
phx-value-field={field}
class="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1 text-left font-normal hover:bg-base-200"
data-testid={"member-sort-#{field}"}
>
<.icon
name="hero-check"
class={"size-4 text-primary #{if @active != field, do: "invisible"}"}
/>
{subfield_label(field)}
</button>
</li>
</ul>
</div>
"""
end
# Which sub-field this column currently sorts by, or nil when another column is
# the active sort. Accepts atoms or the string form that arrives via the URL.
defp active_subfield(sort_field) do
Enum.find(@subfields, fn f -> to_string(sort_field) == Atom.to_string(f) end)
end
defp subfield_label(:first_name), do: gettext("First name")
defp subfield_label(:last_name), do: gettext("Last name")
# The header already shows the active sub-field and direction, so the tooltip
# only needs to convey the click affordance.
defp tooltip_text(nil), do: gettext("Click to sort by last name")
defp tooltip_text(_active), do: gettext("Click to reverse the sort direction")
end

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, [