feat(member-live): sort the composite Member column by first or last name
This commit is contained in:
parent
ca2aaa069c
commit
f7005f395f
11 changed files with 443 additions and 34 deletions
142
lib/mv_web/live/components/member_name_sort_header.ex
Normal file
142
lib/mv_web/live/components/member_name_sort_header.ex
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue