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

@ -926,6 +926,21 @@
pointer-events: none;
}
/*
* Sub-field sort menu (composite Member column). A native Popover-API element in
* the browser TOP LAYER so it escapes the members table's overflow; placement is
* pure CSS anchor positioning (the "⋮" trigger carries anchor-name, this popover
* carries position-anchor). `position-area` drops it just below the trigger,
* right-aligned, and `position-try-fallbacks` flips it above when there is no
* room below. The SubfieldMenu hook only toggles it no coordinate math. This
* mirrors the payment-badge popover approach (§548) for a consistent look/feel.
*/
.popover-menu {
margin: 0;
position-area: bottom span-left;
position-try-fallbacks: flip-block;
}
/*
* Vertically center the row checkbox in the sticky first column using the
* table-native vertical-align: middle. This keeps display: table-cell intact so

View file

@ -407,6 +407,12 @@ Hooks.PopoverTooltip = {
this.el.addEventListener("mouseleave", this.hide)
this.el.addEventListener("blur", this.hide)
this.el.addEventListener("keydown", this.onKey)
// Hide on activation: a click (sort/reset) triggers a LiveView re-render that
// re-emits the tooltip element without the JS-set inline top/left, which would
// otherwise leave the still-open popover stuck at the default (0,0) position,
// covering the header. Closing it here keeps it out of the re-render; the next
// hover repositions it cleanly.
this.el.addEventListener("click", this.hide)
},
destroyed() {
@ -416,6 +422,121 @@ Hooks.PopoverTooltip = {
this.el.removeEventListener("mouseleave", this.hide)
this.el.removeEventListener("blur", this.hide)
this.el.removeEventListener("keydown", this.onKey)
this.el.removeEventListener("click", this.hide)
}
}
// SubfieldMenu hook: toggles a native Popover menu (top layer, so it escapes the
// overview table's overflow clipping) for choosing the composite Member column's
// sort sub-field. Attached to the "⋮" trigger; data-menu-id names the <ul
// popover> menu. Handles click / keyboard open, roving arrow-key focus,
// Escape / click-away close, and closes after a selection (the LiveView
// re-renders on sort_composite anyway). Positioned with getBoundingClientRect
// (portable; no CSS anchor-positioning needed).
Hooks.SubfieldMenu = {
mounted() {
this.menu = document.getElementById(this.el.dataset.menuId)
if (!this.menu || typeof this.menu.showPopover !== "function") return
// Hover/focus hint on the trigger, shown while the menu is closed. Same
// .popover-tooltip look as the sort-header tooltips; placement is CSS anchor
// positioning (no coordinate math).
this.tip = document.getElementById(this.el.dataset.tooltipId)
this.showTip = () => {
if (!this.tip || this.menu.matches(":popover-open")) return
try {
if (!this.tip.matches(":popover-open")) this.tip.showPopover()
} catch (_e) {}
}
this.hideTip = () => {
try {
if (this.tip && this.tip.matches(":popover-open")) this.tip.hidePopover()
} catch (_e) {}
}
this.items = () => [...this.menu.querySelectorAll('[role="menuitemradio"]')]
// Placement is pure CSS anchor positioning (see `.popover-menu` in app.css);
// this hook only toggles visibility, focus and keyboard interaction.
this.open = ({focusFirst = true} = {}) => {
this.hideTip()
if (!this.menu.matches(":popover-open")) this.menu.showPopover()
this.el.setAttribute("aria-expanded", "true")
if (focusFirst) {
const active = this.menu.querySelector('[aria-checked="true"]') || this.items()[0]
if (active) active.focus()
}
}
this.close = ({focusTrigger = false} = {}) => {
if (this.menu.matches(":popover-open")) this.menu.hidePopover()
this.el.setAttribute("aria-expanded", "false")
if (focusTrigger) this.el.focus()
}
this.onTriggerClick = (e) => {
e.preventDefault()
e.stopPropagation()
this.menu.matches(":popover-open") ? this.close() : this.open()
}
this.onTriggerKey = (e) => {
if (["ArrowDown", "Enter", " "].includes(e.key)) {
e.preventDefault()
this.open()
} else if (e.key === "ArrowUp") {
e.preventDefault()
this.open({focusFirst: false})
const items = this.items()
if (items.length) items[items.length - 1].focus()
}
}
this.onMenuKey = (e) => {
const items = this.items()
const i = items.indexOf(document.activeElement)
if (e.key === "ArrowDown") {
e.preventDefault()
;(items[i + 1] || items[0]).focus()
} else if (e.key === "ArrowUp") {
e.preventDefault()
;(items[i - 1] || items[items.length - 1]).focus()
} else if (e.key === "Escape") {
e.preventDefault()
this.close({focusTrigger: true})
} else if (e.key === "Tab") {
this.close()
}
}
// A selection fires the LiveView sort; drop the menu.
this.onMenuClick = () => this.close()
this.onDocPointer = (e) => {
if (!this.el.contains(e.target) && !this.menu.contains(e.target)) this.close()
}
this.el.addEventListener("click", this.onTriggerClick)
this.el.addEventListener("keydown", this.onTriggerKey)
this.el.addEventListener("mouseenter", this.showTip)
this.el.addEventListener("focus", this.showTip)
this.el.addEventListener("mouseleave", this.hideTip)
this.el.addEventListener("blur", this.hideTip)
this.menu.addEventListener("keydown", this.onMenuKey)
this.menu.addEventListener("click", this.onMenuClick)
document.addEventListener("pointerdown", this.onDocPointer, true)
},
destroyed() {
this.el.removeEventListener("click", this.onTriggerClick)
this.el.removeEventListener("keydown", this.onTriggerKey)
this.el.removeEventListener("mouseenter", this.showTip)
this.el.removeEventListener("focus", this.showTip)
this.el.removeEventListener("mouseleave", this.hideTip)
this.el.removeEventListener("blur", this.hideTip)
this.hideTip()
if (this.menu) {
this.menu.removeEventListener("keydown", this.onMenuKey)
this.menu.removeEventListener("click", this.onMenuClick)
if (this.menu.matches(":popover-open")) this.menu.hidePopover()
}
document.removeEventListener("pointerdown", this.onDocPointer, true)
}
}

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

View file

@ -1518,6 +1518,7 @@ msgstr "Mitglieder filtern"
msgid "First Name"
msgstr "Vorname"
#: lib/mv_web/live/components/member_name_sort_header.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
@ -2003,6 +2004,7 @@ msgstr "Letzter Zyklus"
msgid "Last Name"
msgstr "Nachname"
#: lib/mv_web/live/components/member_name_sort_header.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
@ -2349,7 +2351,6 @@ msgstr "Monatliches Intervall Beitrittszeitraum einbezogen"
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
@ -4204,7 +4205,7 @@ msgstr "Kompaktes Mitglied-Feld"
msgid "Click to sort by city"
msgstr "Klicke, um nach Ort zu sortieren"
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Click to sort by last name"
msgstr "Klicke, um nach Nachname zu sortieren"
@ -4213,3 +4214,28 @@ msgstr "Klicke, um nach Nachname zu sortieren"
#, elixir-autogen, elixir-format
msgid "No address"
msgstr "Keine Adresse"
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Choose sort field"
msgstr "Sortierfeld wählen"
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Member"
msgstr "Mitglied"
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Sort members by"
msgstr "Mitglieder sortieren nach"
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Click to reverse the sort direction"
msgstr "Klicke, um die Sortierrichtung umzukehren"
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Sort by"
msgstr "Sortieren nach"

View file

@ -1519,6 +1519,7 @@ msgstr ""
msgid "First Name"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
@ -2004,6 +2005,7 @@ msgstr ""
msgid "Last Name"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
@ -2350,7 +2352,6 @@ msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
@ -4204,7 +4205,7 @@ msgstr ""
msgid "Click to sort by city"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Click to sort by last name"
msgstr ""
@ -4213,3 +4214,28 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "No address"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Choose sort field"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Member"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Sort members by"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Click to reverse the sort direction"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Sort by"
msgstr ""

View file

@ -1519,6 +1519,7 @@ msgstr ""
msgid "First Name"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
@ -2004,6 +2005,7 @@ msgstr ""
msgid "Last Name"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#: lib/mv_web/live/join_request_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
@ -2350,7 +2352,6 @@ msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
@ -4204,7 +4205,7 @@ msgstr ""
msgid "Click to sort by city"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Click to sort by last name"
msgstr ""
@ -4213,3 +4214,28 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "No address"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Choose sort field"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Member"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Sort members by"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Click to reverse the sort direction"
msgstr ""
#: lib/mv_web/live/components/member_name_sort_header.ex
#, elixir-autogen, elixir-format
msgid "Sort by"
msgstr ""

View file

@ -52,13 +52,13 @@ defmodule MvWeb.MemberLive.IndexSortNameAddressTest do
end
test "name sort orders by last name then first name, ignoring email", %{actor: actor} do
asc = OverviewQuery.build(%{sort_field: :name, sort_order: :asc}) |> read_names(actor)
asc = OverviewQuery.build(%{sort_field: :last_name, sort_order: :asc}) |> read_names(actor)
# Both share last name "Meyer"; the first-name tie-breaker orders Anna before
# Bruno, even though Bruno's email sorts first alphabetically.
assert asc == [{"Meyer", "Anna"}, {"Meyer", "Bruno"}]
desc = OverviewQuery.build(%{sort_field: :name, sort_order: :desc}) |> read_names(actor)
desc = OverviewQuery.build(%{sort_field: :last_name, sort_order: :desc}) |> read_names(actor)
assert desc == [{"Meyer", "Bruno"}, {"Meyer", "Anna"}]
end
@ -81,15 +81,48 @@ defmodule MvWeb.MemberLive.IndexSortNameAddressTest do
test "clicking the Name header sorts and reflects state in URL and aria-sort", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
# The composite header sorts by its active sub-field (last name by default).
view |> element("button[phx-click='sort'][data-testid='name']") |> render_click()
path = assert_patch(view)
assert path =~ "sort_field=name"
# Exactly one column carries aria-sort (§1.11).
assert has_element?(view, "th[aria-sort='ascending']")
assert path =~ "sort_field=last_name"
# Last name is already the default sort (ascending), so the click toggles it
# to descending. Exactly one column carries aria-sort (§1.11).
assert has_element?(view, "th[aria-sort='descending']")
assert render(view) |> aria_sort_count() == 1
end
test "the sub-field menu sorts ascending by the chosen field without toggling", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
# Pick "first name" from the composite sub-field menu.
view |> element("[data-testid='member-sort-first_name']") |> render_click()
path = assert_patch(view)
assert path =~ "sort_field=first_name"
assert path =~ "sort_order=asc"
assert has_element?(view, "th[aria-sort='ascending']")
# Selecting the same sub-field again keeps it ascending: the menu switches the
# sort key, it does not toggle direction (that is what a header click does).
view |> element("[data-testid='member-sort-first_name']") |> render_click()
path2 = assert_patch(view)
assert path2 =~ "sort_field=first_name"
assert path2 =~ "sort_order=asc"
end
test "after choosing a sub-field, a header click toggles that field's direction", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
view |> element("[data-testid='member-sort-first_name']") |> render_click()
assert_patch(view)
# Now sorting by first name ascending; a click on the header toggles to desc.
view |> element("button[phx-click='sort'][data-testid='name']") |> render_click()
path = assert_patch(view)
assert path =~ "sort_field=first_name"
assert path =~ "sort_order=desc"
end
test "clicking the Address header sorts and reflects state in URL", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")

View file

@ -221,11 +221,11 @@ defmodule MvWeb.MemberLive.IndexTest do
{:ok, view, _html} =
live(
conn,
"/members?fields=first_name,email,street,house_number,postal_code,city,country,join_date"
"/members?fields=last_name,first_name,email,street,house_number,postal_code,city,country,join_date"
)
# default ascending sorting with first name
assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']")
# default ascending sorting on the default sort field (last name)
assert has_element?(view, "[data-testid='last_name'][aria-label='ascending']")
sortable_fields = [
:email,
@ -295,10 +295,10 @@ defmodule MvWeb.MemberLive.IndexTest do
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=first_name&query=&sort_field=invalid_field&sort_order=asc")
live(conn, "/members?fields=last_name&query=&sort_field=invalid_field&sort_order=asc")
# Should not crash and should show default first name order
assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']")
# Should not crash and should fall back to the default sort (last name)
assert has_element?(view, "[data-testid='last_name'][aria-label='ascending']")
end
test "handle_params preserves search query with sort params", %{conn: conn} do