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
|
|
@ -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
|
||||
|
|
|
|||
121
assets/js/app.js
121
assets/js/app.js
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
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, [
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue