feat(member): make overview columns sortable through accessible sort headers

This commit is contained in:
Simon 2026-07-06 10:48:28 +02:00
parent dfc616257d
commit 0c375234c8
9 changed files with 350 additions and 51 deletions

View file

@ -6,44 +6,57 @@ defmodule MvWeb.Components.SortHeaderComponent do
- label: string() # Column Heading (can be an heex template)
- sort_field: atom() | nil # current sort field from parent liveview
- sort_order: :asc | :desc | nil # current sorting order
- sort_hint: string() | nil # optional; overrides the generic "click
# to sort" tooltip/aria-label for columns
# whose sort key is non-obvious (e.g. the
# Name column sorts by last name)
"""
use MvWeb, :live_component
@impl true
def update(assigns, socket) do
{:ok, assign(socket, assigns)}
{:ok, socket |> assign(assigns) |> assign_new(:sort_hint, fn -> nil end)}
end
# Check if we can add the aria-sort label directly to the daisyUI header
# aria-sort={aria_sort(@field, @sort_field, @sort_order)}
@impl true
def render(assigns) do
assigns =
assigns
|> assign(:tooltip_id, "sort-tooltip-#{assigns.field}")
|> assign(:hint, assigns.sort_hint || gettext("Click to sort"))
~H"""
<div>
<.tooltip content={aria_sort(@field, @sort_field, @sort_order)} position="bottom">
<.button
type="button"
variant="ghost"
aria-label={aria_sort(@field, @sort_field, @sort_order)}
class="select-none"
phx-click="sort"
phx-value-field={@field}
data-testid={@field}
>
{@label}
<%= if @sort_field == @field do %>
<.icon
name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"}
class="sort-icon"
/>
<% else %>
<.icon
name="hero-chevron-up-down"
class="sort-icon opacity-40"
/>
<% end %>
</.button>
</.tooltip>
<button
id={"sort-btn-#{@field}"}
type="button"
aria-label={aria_sort(@field, @sort_field, @sort_order, @hint)}
aria-describedby={@tooltip_id}
class="link link-hover no-underline w-full flex items-center gap-1 text-left text-current select-none"
phx-click="sort"
phx-value-field={@field}
phx-hook="SortTooltip"
data-tooltip-id={@tooltip_id}
data-testid={@field}
>
{@label}
<%= if @sort_field == @field do %>
<.icon
name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"}
class="sort-icon"
/>
<% else %>
<.icon
name="hero-chevron-up-down"
class="sort-icon opacity-40"
/>
<% end %>
</button>
<div id={@tooltip_id} popover="manual" role="tooltip" class="sort-tooltip">
{aria_sort(@field, @sort_field, @sort_order, @hint)}
</div>
</div>
"""
end
@ -51,13 +64,13 @@ defmodule MvWeb.Components.SortHeaderComponent do
# -------------------------------------------------
# Hilfsfunktionen für ARIA Attribute & Icon SVG
# -------------------------------------------------
defp aria_sort(field, sort_field, dir) when field == sort_field do
defp aria_sort(field, sort_field, dir, hint) when field == sort_field do
case dir do
:asc -> gettext("ascending")
:desc -> gettext("descending")
nil -> gettext("Click to sort")
nil -> hint
end
end
defp aria_sort(_, _, _), do: gettext("Click to sort")
defp aria_sort(_, _, _, hint), do: hint
end

View file

@ -1450,7 +1450,9 @@ defmodule MvWeb.MemberLive.Index do
defp valid_sort_field_db_or_custom?(field) when is_atom(field) do
non_sortable_fields = [:notes]
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
field in valid_fields or custom_field_sort?(field) or field in [:groups, :membership_fee_type]
field in valid_fields or custom_field_sort?(field) or
field in [:groups, :membership_fee_type, :name, :address]
end
defp valid_sort_field_db_or_custom?(field) when is_binary(field) do
@ -1458,6 +1460,8 @@ defmodule MvWeb.MemberLive.Index do
cond do
field == "groups" -> :groups
field == "membership_fee_type" -> :membership_fee_type
field == "name" -> :name
field == "address" -> :address
true -> safe_member_field_atom_only(field)
end
@ -1508,10 +1512,16 @@ defmodule MvWeb.MemberLive.Index do
defp determine_field(default, ""), do: default
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]
defp determine_field(default, sf) when is_binary(sf) do
# Handle "groups" specially - it's in computed_member_fields() but can be sorted
if sf == "groups" do
:groups
sortable_strings = Enum.map(@sortable_computed_fields, &Atom.to_string/1)
if sf in sortable_strings do
String.to_existing_atom(sf)
else
computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1)
@ -1522,9 +1532,8 @@ defmodule MvWeb.MemberLive.Index do
end
defp determine_field(default, sf) when is_atom(sf) do
# Handle :groups specially - it's in computed_member_fields() but can be sorted
if sf == :groups do
:groups
if sf in @sortable_computed_fields do
sf
else
if sf in FieldVisibility.computed_member_fields(),
do: default,

View file

@ -5,6 +5,7 @@
<.live_component
module={MvWeb.Components.BulkActionsDropdown}
id="bulk-actions-dropdown"
open={@bulk_actions_open}
export_payload_json={@export_payload_json}
selected_count={@selected_count}
scope={@scope}
@ -52,7 +53,9 @@
<.button
type="button"
variant="secondary"
class={["gap-2", @show_current_cycle && "btn-active"]}
class="gap-2"
active={@show_current_cycle}
aria-pressed={to_string(@show_current_cycle)}
phx-click="toggle_cycle_view"
data-testid="toggle-cycle-view"
aria-label={
@ -135,8 +138,6 @@
sort_order={@sort_order}
size_class={if @density == :compact, do: "table-xs", else: "table-md"}
>
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
<:col
:let={member}
col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
@ -285,10 +286,12 @@
[member.postal_code, member.city]
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.join(" ") %>
<div data-testid="member-address">
<div class="truncate" title={line1}>{line1}</div>
<div class="opacity-70 truncate" title={line2}>{line2}</div>
</div>
<.maybe_value value={line1 <> line2} empty_sr_text={gettext("No address")}>
<div data-testid="member-address">
<div class="truncate" title={line1}>{line1}</div>
<div class="opacity-70 truncate" title={line2}>{line2}</div>
</div>
</.maybe_value>
</:col>
<:col
:let={member}
@ -307,7 +310,9 @@
"""
}
>
{member.street}
<.maybe_value value={member.street} empty_sr_text={gettext("Not specified")}>
{member.street}
</.maybe_value>
</:col>
<:col
:let={member}
@ -326,7 +331,9 @@
"""
}
>
{member.house_number}
<.maybe_value value={member.house_number} empty_sr_text={gettext("Not specified")}>
{member.house_number}
</.maybe_value>
</:col>
<:col
:let={member}
@ -345,7 +352,9 @@
"""
}
>
{member.postal_code}
<.maybe_value value={member.postal_code} empty_sr_text={gettext("Not specified")}>
{member.postal_code}
</.maybe_value>
</:col>
<:col
:let={member}
@ -364,7 +373,9 @@
"""
}
>
{member.city}
<.maybe_value value={member.city} empty_sr_text={gettext("Not specified")}>
{member.city}
</.maybe_value>
</:col>
<:col
:let={member}
@ -551,7 +562,7 @@
class="sticky left-0 flex items-center justify-center gap-2 text-base-content/70"
data-testid="members-loading-row"
>
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
<.spinner size="sm" aria-hidden="true" />
{gettext("Loading more members …")}
</div>
</:footer>

View file

@ -254,6 +254,22 @@ 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
Ash.Query.sort(query, [{:last_name, order}, {:first_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, [
{:city, order},
{:postal_code, order},
{:street, order},
{:id, :asc}
])
end
defp apply_sort(query, field, order, custom_fields) do
case sort_key(field) do
nil -> apply_custom_field_sort(query, field, order, custom_fields)