Rework member overview #553
11 changed files with 443 additions and 34 deletions
|
|
@ -926,6 +926,21 @@
|
||||||
pointer-events: none;
|
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
|
* Vertically center the row checkbox in the sticky first column using the
|
||||||
* table-native vertical-align: middle. This keeps display: table-cell intact so
|
* 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("mouseleave", this.hide)
|
||||||
this.el.addEventListener("blur", this.hide)
|
this.el.addEventListener("blur", this.hide)
|
||||||
this.el.addEventListener("keydown", this.onKey)
|
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() {
|
destroyed() {
|
||||||
|
|
@ -416,6 +422,121 @@ Hooks.PopoverTooltip = {
|
||||||
this.el.removeEventListener("mouseleave", this.hide)
|
this.el.removeEventListener("mouseleave", this.hide)
|
||||||
this.el.removeEventListener("blur", this.hide)
|
this.el.removeEventListener("blur", this.hide)
|
||||||
this.el.removeEventListener("keydown", this.onKey)
|
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
|
socket
|
||||||
|> Layouts.assign_page_title(gettext("Members"))
|
|> Layouts.assign_page_title(gettext("Members"))
|
||||||
|> assign(:query, "")
|
|> 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_new(:sort_order, fn -> :asc end)
|
||||||
|> assign(:cycle_status_filter, nil)
|
|> assign(:cycle_status_filter, nil)
|
||||||
|> assign(:group_filters, %{})
|
|> assign(:group_filters, %{})
|
||||||
|
|
@ -379,6 +379,22 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
|
|
||||||
{new_field, new_order} = determine_new_sort(field, socket)
|
{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
|
old_field = socket.assigns.sort_field
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
|
|
@ -394,8 +410,8 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
query_params =
|
query_params =
|
||||||
build_query_params(
|
build_query_params(
|
||||||
opts_for_query_params(socket, %{
|
opts_for_query_params(socket, %{
|
||||||
sort_field: ExportPayload.sort_field(socket.assigns.sort_field),
|
sort_field: ExportPayload.sort_field(new_field),
|
||||||
sort_order: ExportPayload.sort_order(socket.assigns.sort_order)
|
sort_order: ExportPayload.sort_order(new_order)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|> maybe_add_field_selection(
|
|> maybe_add_field_selection(
|
||||||
|
|
@ -403,7 +419,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns[:fields_in_url?] || false
|
socket.assigns[:fields_in_url?] || false
|
||||||
)
|
)
|
||||||
|
|
||||||
{:noreply, push_reload(socket, ~p"/members?#{query_params}")}
|
push_reload(socket, ~p"/members?#{query_params}")
|
||||||
end
|
end
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
|
|
@ -1488,9 +1504,11 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
defp determine_field(default, nil), do: default
|
defp determine_field(default, nil), do: default
|
||||||
|
|
||||||
# Computed/pseudo fields that are nonetheless sortable (they resolve to DB
|
# Computed/pseudo fields that are nonetheless sortable (they resolve to DB
|
||||||
# sort keys in OverviewQuery): groups aggregate and the composite Name/Address
|
# sort keys in OverviewQuery): the groups aggregate and the composite Address
|
||||||
# columns. Other computed fields (e.g. membership_fee_status) are not sortable.
|
# column. The composite Member column sorts by its real first/last-name
|
||||||
@sortable_computed_fields [:groups, :name, :address]
|
# 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
|
defp determine_field(default, sf) when is_binary(sf) do
|
||||||
sortable_strings = Enum.map(@sortable_computed_fields, &Atom.to_string/1)
|
sortable_strings = Enum.map(@sortable_computed_fields, &Atom.to_string/1)
|
||||||
|
|
|
||||||
|
|
@ -170,17 +170,12 @@
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:name in @member_fields_visible and @compact_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={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<MvWeb.Components.MemberNameSortHeader.composite_name_sort_header
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
|
||||||
id={:sort_name}
|
|
||||||
field={:name}
|
|
||||||
label={gettext("Name")}
|
|
||||||
sort_field={@sort_field}
|
sort_field={@sort_field}
|
||||||
sort_order={@sort_order}
|
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, 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)
|
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
|
# Composite "Member" column sub-fields (§1.21). Sorting by one name part uses
|
||||||
# shares the cell is display-only and does not affect the sort key (§1.21).
|
# the other name part (then id) as a stable tie-breaker; the email that shares
|
||||||
defp apply_sort(query, field, order, _custom_fields) when field in [:name, "name"] do
|
# 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}])
|
Ash.Query.sort(query, [{:last_name, order}, {:first_name, order}, {:id, :asc}])
|
||||||
end
|
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.
|
# 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
|
defp apply_sort(query, field, order, _custom_fields) when field in [:address, "address"] do
|
||||||
Ash.Query.sort(query, [
|
Ash.Query.sort(query, [
|
||||||
|
|
|
||||||
|
|
@ -1518,6 +1518,7 @@ msgstr "Mitglieder filtern"
|
||||||
msgid "First Name"
|
msgid "First Name"
|
||||||
msgstr "Vorname"
|
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/join_request_live/index.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -2003,6 +2004,7 @@ msgstr "Letzter Zyklus"
|
||||||
msgid "Last Name"
|
msgid "Last Name"
|
||||||
msgstr "Nachname"
|
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/join_request_live/index.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, 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/group_live/show.ex
|
||||||
#: lib/mv_web/live/member_field_live/form_component.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_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_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.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"
|
msgid "Click to sort by city"
|
||||||
msgstr "Klicke, um nach Ort zu sortieren"
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Click to sort by last name"
|
msgid "Click to sort by last name"
|
||||||
msgstr "Klicke, um nach Nachname zu sortieren"
|
msgstr "Klicke, um nach Nachname zu sortieren"
|
||||||
|
|
@ -4213,3 +4214,28 @@ msgstr "Klicke, um nach Nachname zu sortieren"
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No address"
|
msgid "No address"
|
||||||
msgstr "Keine Adresse"
|
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"
|
msgid "First Name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/member_name_sort_header.ex
|
||||||
#: lib/mv_web/live/join_request_live/index.ex
|
#: lib/mv_web/live/join_request_live/index.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -2004,6 +2005,7 @@ msgstr ""
|
||||||
msgid "Last Name"
|
msgid "Last Name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/member_name_sort_header.ex
|
||||||
#: lib/mv_web/live/join_request_live/index.ex
|
#: lib/mv_web/live/join_request_live/index.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -2350,7 +2352,6 @@ msgstr ""
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: 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/form_component.ex
|
||||||
#: lib/mv_web/live/member_field_live/index_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_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
|
|
@ -4204,7 +4205,7 @@ msgstr ""
|
||||||
msgid "Click to sort by city"
|
msgid "Click to sort by city"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/components/member_name_sort_header.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Click to sort by last name"
|
msgid "Click to sort by last name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -4213,3 +4214,28 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No address"
|
msgid "No address"
|
||||||
msgstr ""
|
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"
|
msgid "First Name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/member_name_sort_header.ex
|
||||||
#: lib/mv_web/live/join_request_live/index.ex
|
#: lib/mv_web/live/join_request_live/index.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
|
@ -2004,6 +2005,7 @@ msgstr ""
|
||||||
msgid "Last Name"
|
msgid "Last Name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/member_name_sort_header.ex
|
||||||
#: lib/mv_web/live/join_request_live/index.ex
|
#: lib/mv_web/live/join_request_live/index.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
|
@ -2350,7 +2352,6 @@ msgstr ""
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: 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/form_component.ex
|
||||||
#: lib/mv_web/live/member_field_live/index_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_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
|
|
@ -4204,7 +4205,7 @@ msgstr ""
|
||||||
msgid "Click to sort by city"
|
msgid "Click to sort by city"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/components/member_name_sort_header.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Click to sort by last name"
|
msgid "Click to sort by last name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -4213,3 +4214,28 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No address"
|
msgid "No address"
|
||||||
msgstr ""
|
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
|
end
|
||||||
|
|
||||||
test "name sort orders by last name then first name, ignoring email", %{actor: actor} do
|
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
|
# Both share last name "Meyer"; the first-name tie-breaker orders Anna before
|
||||||
# Bruno, even though Bruno's email sorts first alphabetically.
|
# Bruno, even though Bruno's email sorts first alphabetically.
|
||||||
assert asc == [{"Meyer", "Anna"}, {"Meyer", "Bruno"}]
|
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"}]
|
assert desc == [{"Meyer", "Bruno"}, {"Meyer", "Anna"}]
|
||||||
end
|
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
|
test "clicking the Name header sorts and reflects state in URL and aria-sort", %{conn: conn} do
|
||||||
{:ok, view, _html} = live(conn, ~p"/members")
|
{: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()
|
view |> element("button[phx-click='sort'][data-testid='name']") |> render_click()
|
||||||
|
|
||||||
path = assert_patch(view)
|
path = assert_patch(view)
|
||||||
assert path =~ "sort_field=name"
|
assert path =~ "sort_field=last_name"
|
||||||
# Exactly one column carries aria-sort (§1.11).
|
# Last name is already the default sort (ascending), so the click toggles it
|
||||||
assert has_element?(view, "th[aria-sort='ascending']")
|
# 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
|
assert render(view) |> aria_sort_count() == 1
|
||||||
end
|
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
|
test "clicking the Address header sorts and reflects state in URL", %{conn: conn} do
|
||||||
{:ok, view, _html} = live(conn, ~p"/members")
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -221,11 +221,11 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
{:ok, view, _html} =
|
{:ok, view, _html} =
|
||||||
live(
|
live(
|
||||||
conn,
|
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
|
# default ascending sorting on the default sort field (last name)
|
||||||
assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']")
|
assert has_element?(view, "[data-testid='last_name'][aria-label='ascending']")
|
||||||
|
|
||||||
sortable_fields = [
|
sortable_fields = [
|
||||||
:email,
|
:email,
|
||||||
|
|
@ -295,10 +295,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
|
|
||||||
{:ok, view, _html} =
|
{: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
|
# Should not crash and should fall back to the default sort (last name)
|
||||||
assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']")
|
assert has_element?(view, "[data-testid='last_name'][aria-label='ascending']")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handle_params preserves search query with sort params", %{conn: conn} do
|
test "handle_params preserves search query with sort params", %{conn: conn} do
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue