Merge pull request 'Sorting header for members list closes #152 #175' (#166) from feature/152_sorting_default_fields into main
All checks were successful
continuous-integration/drone/push Build is passing

Reviewed-on: #166
Reviewed-by: moritz <moritz@noreply.git.local-it.org>
This commit is contained in:
moritz 2025-10-30 16:44:49 +01:00
commit c1f9750972
13 changed files with 1010 additions and 199 deletions

View file

@ -8,10 +8,10 @@ defmodule MvWeb.Components.SearchBarComponent do
use MvWeb, :live_component
@impl true
def update(_assigns, socket) do
def update(%{query: query}, socket) do
socket =
socket
|> assign_new(:query, fn -> "" end)
|> assign_new(:query, fn -> query || "" end)
|> assign_new(:placeholder, fn -> gettext("Search...") end)
{:ok, socket}
@ -20,7 +20,7 @@ defmodule MvWeb.Components.SearchBarComponent do
@impl true
def render(assigns) do
~H"""
<form phx-change="search" phx-target={@myself} class="flex" role="search" aria-label="Search">
<form phx-submit="search" phx-target={@myself} class="flex" role="search" aria-label="Search">
<label class="input">
<svg
class="h-[1em] opacity-50"
@ -44,6 +44,9 @@ defmodule MvWeb.Components.SearchBarComponent do
placeholder={@placeholder}
value={@query}
name="query"
data-testid="search-input"
phx-change="search"
phx-target={@myself}
phx-debounce="300"
/>
</label>

View file

@ -0,0 +1,64 @@
defmodule MvWeb.Components.SortHeaderComponent do
@moduledoc """
Sort Header that can be used as column header and sorts a table:
Props:
- field: atom() # Ash Field for sorting
- 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
"""
use MvWeb, :live_component
@impl true
def update(assigns, socket) do
{:ok, assign(socket, assigns)}
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
~H"""
<div class="tooltip" data-tip={aria_sort(@field, @sort_field, @sort_order)}>
<button
type="button"
aria-label={aria_sort(@field, @sort_field, @sort_order)}
class="btn btn-ghost select-none"
phx-click="sort"
phx-value-field={@field}
phx-target={@myself}
data-testid={@field}
>
{@label}
<%= if @sort_field == @field do %>
<.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} />
<% else %>
<.icon
name="hero-chevron-up-down"
class="opacity-40"
/>
<% end %>
</button>
</div>
"""
end
@impl true
def handle_event("sort", %{"field" => field_str}, socket) do
send(self(), {:sort, field_str})
{:noreply, socket}
end
# -------------------------------------------------
# Hilfsfunktionen für ARIA Attribute & Icon SVG
# -------------------------------------------------
defp aria_sort(field, sort_field, dir) when field == sort_field do
case dir do
:asc -> gettext("ascending")
:desc -> gettext("descending")
nil -> gettext("Click to sort")
end
end
defp aria_sort(_, _, _), do: gettext("Click to sort")
end

View file

@ -2,49 +2,26 @@ defmodule MvWeb.MemberLive.Index do
use MvWeb, :live_view
import Ash.Expr
import Ash.Query
import MvWeb.TableComponents
@impl true
def mount(_params, _session, socket) do
members = Ash.read!(Mv.Membership.Member)
sorted = Enum.sort_by(members, & &1.first_name)
socket =
socket
|> assign(:page_title, gettext("Members"))
|> assign(:query, "")
|> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:selected_members, [])
{:ok,
socket
|> assign(:page_title, gettext("Members"))
|> assign(:query, "")
|> assign(:sort_field, :first_name)
|> assign(:sort_order, :asc)
|> assign(:members, sorted)
|> assign(:selected_members, [])}
end
# -----------------------------------------------------------------
# Receive messages from any toolbar component
# -----------------------------------------------------------------
# Function to handle search
@impl true
def handle_info({:search_changed, q}, socket) do
members =
if String.trim(q) == "" do
Ash.read!(Mv.Membership.Member)
else
Mv.Membership.Member
|> filter(expr(fragment("search_vector @@ plainto_tsquery('simple', ?)", ^q)))
|> Ash.read!()
end
{:noreply,
socket
|> assign(:query, q)
|> assign(:members, members)}
# We call handle params to use the query from the URL
{:ok, socket}
end
# -----------------------------------------------------------------
# Handle Events
# -----------------------------------------------------------------
# Delete a member
@impl true
def handle_event("delete", %{"id" => id}, socket) do
member = Ash.get!(Mv.Membership.Member, id)
@ -67,32 +44,7 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, assign(socket, :selected_members, selected)}
end
# Sorts the list of members according to a field, when you click on the column header
@impl true
def handle_event("sort", %{"field" => field_str}, socket) do
members = socket.assigns.members
field = String.to_existing_atom(field_str)
new_order =
if socket.assigns.sort_field == field do
toggle_order(socket.assigns.sort_order)
else
:asc
end
sorted_members =
members
|> Enum.sort_by(&Map.get(&1, field), sort_fun(new_order))
{:noreply,
socket
|> assign(:sort_field, field)
|> assign(:sort_order, new_order)
|> assign(:members, sorted_members)}
end
# Selects all members in the list of members
# Selects all members in the list of members
@impl true
def handle_event("select_all", _params, socket) do
members = socket.assigns.members
@ -109,8 +61,235 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, assign(socket, :selected_members, selected)}
end
# -----------------------------------------------------------------
# Handle Infos from Child Components
# -----------------------------------------------------------------
# Sorts the list of members according to a field, when you click on the column header
@impl true
def handle_info({:sort, field_str}, socket) do
field = String.to_existing_atom(field_str)
old_field = socket.assigns.sort_field
{new_order, new_field} =
if socket.assigns.sort_field == field do
{toggle_order(socket.assigns.sort_order), field}
else
{:asc, field}
end
active_id = :"sort_#{new_field}"
old_id = :"sort_#{old_field}"
# Update the new SortHeader
send_update(MvWeb.Components.SortHeaderComponent,
id: active_id,
sort_field: new_field,
sort_order: new_order
)
# Reset the current SortHeader
send_update(MvWeb.Components.SortHeaderComponent,
id: old_id,
sort_field: new_field,
sort_order: new_order
)
existing_search_query = socket.assigns.query
# Build the URL with queries
query_params = %{
"query" => existing_search_query,
"sort_field" => Atom.to_string(new_field),
"sort_order" => Atom.to_string(new_order)
}
# Set the new path with params
new_path = ~p"/members?#{query_params}"
# Push the new URL
{:noreply,
push_patch(socket,
to: new_path,
replace: true
)}
end
# Function to handle search
@impl true
def handle_info({:search_changed, q}, socket) do
socket = load_members(socket, q)
existing_field_query = socket.assigns.sort_field
existing_sort_query = socket.assigns.sort_order
# Build the URL with queries
query_params = %{
"query" => q,
"sort_field" => existing_field_query,
"sort_order" => existing_sort_query
}
# Set the new path with params
new_path = ~p"/members?#{query_params}"
# Push the new URL
{:noreply,
push_patch(socket,
to: new_path,
replace: true
)}
end
# -----------------------------------------------------------------
# Handle Params from the URL
# -----------------------------------------------------------------
@impl true
def handle_params(params, _url, socket) do
socket =
socket
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> load_members(params["query"])
{:noreply, socket}
end
# -------------------------------------------------------------
# FUNCTIONS
# -------------------------------------------------------------
# Load members eg based on a query for sorting
defp load_members(socket, search_query) do
query =
Mv.Membership.Member
|> Ash.Query.new()
|> Ash.Query.select([
:id,
:first_name,
:last_name,
:email,
:street,
:house_number,
:postal_code,
:city,
:phone_number,
:join_date
])
# Apply the search filter first
query = apply_search_filter(query, search_query)
# Apply sorting based on current socket state
query = maybe_sort(query, socket.assigns.sort_field, socket.assigns.sort_order)
members = Ash.read!(query)
assign(socket, :members, members)
end
# -------------------------------------------------------------
# Helper Functions
# -------------------------------------------------------------
# Function to apply search query
defp apply_search_filter(query, search_query) do
if search_query && String.trim(search_query) != "" do
query
|> filter(expr(fragment("search_vector @@ plainto_tsquery('simple', ?)", ^search_query)))
else
query
end
end
# Functions to toggle sorting order
defp toggle_order(:asc), do: :desc
defp toggle_order(:desc), do: :asc
defp sort_fun(:asc), do: &<=/2
defp sort_fun(:desc), do: &>=/2
defp toggle_order(nil), do: :asc
# Function to sort the column if needed
defp maybe_sort(query, nil, _), do: query
defp maybe_sort(query, field, :asc) when not is_nil(field),
do: Ash.Query.sort(query, [{field, :asc}])
defp maybe_sort(query, field, :desc) when not is_nil(field),
do: Ash.Query.sort(query, [{field, :desc}])
defp maybe_sort(query, _, _), do: query
# Validate that a field is sortable
defp valid_sort_field?(field) when is_atom(field) do
valid_fields = [
:first_name,
:last_name,
:email,
:street,
:house_number,
:postal_code,
:city,
:phone_number,
:join_date
]
field in valid_fields
end
defp valid_sort_field?(_), do: false
# Function to maybe update the sort
defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do
field = determine_field(socket.assigns.sort_field, sf)
order = determine_order(socket.assigns.sort_order, so)
socket
|> assign(:sort_field, field)
|> assign(:sort_order, order)
end
defp maybe_update_sort(socket, _), do: socket
defp determine_field(default, sf) do
case sf do
"" ->
default
nil ->
default
sf when is_binary(sf) ->
sf
|> String.to_existing_atom()
|> handle_atom_conversion(default)
sf when is_atom(sf) ->
handle_atom_conversion(sf, default)
_ ->
default
end
end
defp handle_atom_conversion(val, default) when is_atom(val) do
if valid_sort_field?(val), do: val, else: default
end
defp handle_atom_conversion(_, default), do: default
defp determine_order(default, so) do
case so do
"" -> default
nil -> default
so when so in ["asc", "desc"] -> String.to_atom(so)
_ -> default
end
end
# Function to update search parameters
defp maybe_update_search(socket, %{"query" => query}) when query != "" do
assign(socket, :query, query)
end
defp maybe_update_search(socket, _params) do
# Keep the previous search query if no new one is provided
socket
end
end

View file

@ -52,23 +52,139 @@
<:col
:let={member}
label={
sort_button(%{
field: :first_name,
label: gettext("Name"),
sort_field: @sort_field,
sort_order: @sort_order
})
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_first_name}
field={:first_name}
label={gettext("First name")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.first_name} {member.last_name}
</:col>
<:col :let={member} label={gettext("Email")}>{member.email}</:col>
<:col :let={member} label={gettext("Street")}>{member.street}</:col>
<:col :let={member} label={gettext("House Number")}>{member.house_number}</:col>
<:col :let={member} label={gettext("Postal Code")}>{member.postal_code}</:col>
<:col :let={member} label={gettext("City")}>{member.city}</:col>
<:col :let={member} label={gettext("Phone Number")}>{member.phone_number}</:col>
<:col :let={member} label={gettext("Join Date")}>{member.join_date}</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_email}
field={:email}
label={gettext("Email")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.email}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_street}
field={:street}
label={gettext("Street")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.street}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_house_number}
field={:house_number}
label={gettext("House Number")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.house_number}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_postal_code}
field={:postal_code}
label={gettext("Postal Code")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.postal_code}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_city}
field={:city}
label={gettext("City")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.city}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_phone_number}
field={:phone_number}
label={gettext("Phone Number")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.phone_number}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_join_date}
field={:join_date}
label={gettext("Join Date")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.join_date}
</:col>
<:action :let={member}>
<div class="sr-only">

View file

@ -61,3 +61,6 @@ msgstr "Anmelden..."
msgid "Your password has successfully been reset"
msgstr "Das Passwort wurde erfolgreich zurückgesetzt"
#~ msgid "Sign in with Rauthy"
#~ msgstr "Anmelden mit der Vereinscloud"

View file

@ -15,7 +15,7 @@ msgstr ""
msgid "Actions"
msgstr "Aktionen"
#: lib/mv_web/live/member_live/index.html.heex:84
#: lib/mv_web/live/member_live/index.html.heex:193
#: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format
msgid "Are you sure?"
@ -28,19 +28,19 @@ msgid "Attempting to reconnect"
msgstr "Verbindung wird wiederhergestellt"
#: lib/mv_web/live/member_live/form.ex:25
#: lib/mv_web/live/member_live/index.html.heex:69
#: lib/mv_web/live/member_live/show.ex:37
#: lib/mv_web/live/member_live/index.html.heex:138
#: lib/mv_web/live/member_live/show.ex:36
#, elixir-autogen, elixir-format
msgid "City"
msgstr "Stadt"
#: lib/mv_web/live/member_live/index.html.heex:86
#: lib/mv_web/live/member_live/index.html.heex:195
#: lib/mv_web/live/user_live/index.html.heex:67
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr "Löschen"
#: lib/mv_web/live/member_live/index.html.heex:78
#: lib/mv_web/live/member_live/index.html.heex:187
#: lib/mv_web/live/user_live/form.ex:109
#: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format
@ -54,8 +54,8 @@ msgid "Edit Member"
msgstr "Mitglied bearbeiten"
#: lib/mv_web/live/member_live/form.ex:18
#: lib/mv_web/live/member_live/index.html.heex:65
#: lib/mv_web/live/member_live/show.ex:28
#: lib/mv_web/live/member_live/index.html.heex:70
#: lib/mv_web/live/member_live/show.ex:27
#: lib/mv_web/live/user_live/form.ex:14
#: lib/mv_web/live/user_live/index.html.heex:44
#: lib/mv_web/live/user_live/show.ex:25
@ -70,8 +70,8 @@ msgid "First Name"
msgstr "Vorname"
#: lib/mv_web/live/member_live/form.ex:22
#: lib/mv_web/live/member_live/index.html.heex:71
#: lib/mv_web/live/member_live/show.ex:34
#: lib/mv_web/live/member_live/index.html.heex:172
#: lib/mv_web/live/member_live/show.ex:33
#, elixir-autogen, elixir-format
msgid "Join Date"
msgstr "Beitrittsdatum"
@ -87,7 +87,7 @@ msgstr "Nachname"
msgid "New Member"
msgstr "Neues Mitglied"
#: lib/mv_web/live/member_live/index.html.heex:75
#: lib/mv_web/live/member_live/index.html.heex:184
#: lib/mv_web/live/user_live/index.html.heex:56
#, elixir-autogen, elixir-format
msgid "Show"
@ -127,8 +127,8 @@ msgid "Exit Date"
msgstr "Austrittsdatum"
#: lib/mv_web/live/member_live/form.ex:27
#: lib/mv_web/live/member_live/index.html.heex:67
#: lib/mv_web/live/member_live/show.ex:39
#: lib/mv_web/live/member_live/index.html.heex:104
#: lib/mv_web/live/member_live/show.ex:38
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr "Hausnummer"
@ -146,15 +146,15 @@ msgid "Paid"
msgstr "Bezahlt"
#: lib/mv_web/live/member_live/form.ex:21
#: lib/mv_web/live/member_live/index.html.heex:70
#: lib/mv_web/live/member_live/show.ex:33
#: lib/mv_web/live/member_live/index.html.heex:155
#: lib/mv_web/live/member_live/show.ex:32
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr "Telefonnummer"
#: lib/mv_web/live/member_live/form.ex:28
#: lib/mv_web/live/member_live/index.html.heex:68
#: lib/mv_web/live/member_live/show.ex:40
#: lib/mv_web/live/member_live/index.html.heex:121
#: lib/mv_web/live/member_live/show.ex:39
#, elixir-autogen, elixir-format
msgid "Postal Code"
msgstr "Postleitzahl"
@ -173,8 +173,8 @@ msgid "Saving..."
msgstr "Speichern..."
#: lib/mv_web/live/member_live/form.ex:26
#: lib/mv_web/live/member_live/index.html.heex:66
#: lib/mv_web/live/member_live/show.ex:38
#: lib/mv_web/live/member_live/index.html.heex:87
#: lib/mv_web/live/member_live/show.ex:37
#, elixir-autogen, elixir-format
msgid "Street"
msgstr "Straße"
@ -317,14 +317,13 @@ msgstr "Benutzer*innen auflisten"
msgid "Member"
msgstr "Mitglied"
#: lib/mv_web/components/layouts/navbar.ex:19
#: lib/mv_web/live/member_live/index.ex:14
#: lib/mv_web/components/layouts/navbar.ex:14
#: lib/mv_web/live/member_live/index.ex:8
#: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format
msgid "Members"
msgstr "Mitglieder"
#: lib/mv_web/live/member_live/index.html.heex:57
#: lib/mv_web/live/property_type_live/form.ex:16
#, elixir-autogen, elixir-format
msgid "Name"
@ -469,11 +468,13 @@ msgid "Value type"
msgstr "Wertetyp"
#: lib/mv_web/components/table_components.ex:30
#: lib/mv_web/live/components/sort_header_component.ex:55
#, elixir-autogen, elixir-format
msgid "ascending"
msgstr "aufsteigend"
#: lib/mv_web/components/table_components.ex:30
#: lib/mv_web/live/components/sort_header_component.ex:56
#, elixir-autogen, elixir-format
msgid "descending"
msgstr "absteigend"
@ -600,10 +601,15 @@ msgstr "Dunklen Modus umschalten"
#: lib/mv_web/live/components/search_bar_component.ex:15
#: lib/mv_web/live/member_live/index.html.heex:15
#, elixir-autogen, elixir-format
msgid "Search..."
msgstr ""
msgid "Click to sort"
msgstr "Klicke um zu sortieren"
#: lib/mv_web/components/layouts/navbar.ex:20
#: lib/mv_web/live/member_live/index.html.heex:53
#, elixir-autogen, elixir-format, fuzzy
msgid "Users"
msgstr "Benutzer*innen"
msgid "First name"
msgstr "Vorname"
#~ #: lib/mv_web/auth_overrides.ex:30
#~ #, elixir-autogen, elixir-format
#~ msgid "or"
#~ msgstr "oder"

View file

@ -16,7 +16,7 @@ msgstr ""
msgid "Actions"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:84
#: lib/mv_web/live/member_live/index.html.heex:193
#: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format
msgid "Are you sure?"
@ -29,19 +29,19 @@ msgid "Attempting to reconnect"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:25
#: lib/mv_web/live/member_live/index.html.heex:69
#: lib/mv_web/live/member_live/show.ex:37
#: lib/mv_web/live/member_live/index.html.heex:138
#: lib/mv_web/live/member_live/show.ex:36
#, elixir-autogen, elixir-format
msgid "City"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:86
#: lib/mv_web/live/member_live/index.html.heex:195
#: lib/mv_web/live/user_live/index.html.heex:67
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:78
#: lib/mv_web/live/member_live/index.html.heex:187
#: lib/mv_web/live/user_live/form.ex:109
#: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format
@ -55,8 +55,8 @@ msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:18
#: lib/mv_web/live/member_live/index.html.heex:65
#: lib/mv_web/live/member_live/show.ex:28
#: lib/mv_web/live/member_live/index.html.heex:70
#: lib/mv_web/live/member_live/show.ex:27
#: lib/mv_web/live/user_live/form.ex:14
#: lib/mv_web/live/user_live/index.html.heex:44
#: lib/mv_web/live/user_live/show.ex:25
@ -71,8 +71,8 @@ msgid "First Name"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:22
#: lib/mv_web/live/member_live/index.html.heex:71
#: lib/mv_web/live/member_live/show.ex:34
#: lib/mv_web/live/member_live/index.html.heex:172
#: lib/mv_web/live/member_live/show.ex:33
#, elixir-autogen, elixir-format
msgid "Join Date"
msgstr ""
@ -88,7 +88,7 @@ msgstr ""
msgid "New Member"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:75
#: lib/mv_web/live/member_live/index.html.heex:184
#: lib/mv_web/live/user_live/index.html.heex:56
#, elixir-autogen, elixir-format
msgid "Show"
@ -128,8 +128,8 @@ msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:27
#: lib/mv_web/live/member_live/index.html.heex:67
#: lib/mv_web/live/member_live/show.ex:39
#: lib/mv_web/live/member_live/index.html.heex:104
#: lib/mv_web/live/member_live/show.ex:38
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr ""
@ -147,15 +147,15 @@ msgid "Paid"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:21
#: lib/mv_web/live/member_live/index.html.heex:70
#: lib/mv_web/live/member_live/show.ex:33
#: lib/mv_web/live/member_live/index.html.heex:155
#: lib/mv_web/live/member_live/show.ex:32
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:28
#: lib/mv_web/live/member_live/index.html.heex:68
#: lib/mv_web/live/member_live/show.ex:40
#: lib/mv_web/live/member_live/index.html.heex:121
#: lib/mv_web/live/member_live/show.ex:39
#, elixir-autogen, elixir-format
msgid "Postal Code"
msgstr ""
@ -174,8 +174,8 @@ msgid "Saving..."
msgstr ""
#: lib/mv_web/live/member_live/form.ex:26
#: lib/mv_web/live/member_live/index.html.heex:66
#: lib/mv_web/live/member_live/show.ex:38
#: lib/mv_web/live/member_live/index.html.heex:87
#: lib/mv_web/live/member_live/show.ex:37
#, elixir-autogen, elixir-format
msgid "Street"
msgstr ""
@ -318,14 +318,13 @@ msgstr ""
msgid "Member"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:19
#: lib/mv_web/live/member_live/index.ex:14
#: lib/mv_web/components/layouts/navbar.ex:14
#: lib/mv_web/live/member_live/index.ex:8
#: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format
msgid "Members"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:57
#: lib/mv_web/live/property_type_live/form.ex:16
#, elixir-autogen, elixir-format
msgid "Name"
@ -470,11 +469,13 @@ msgid "Value type"
msgstr ""
#: lib/mv_web/components/table_components.ex:30
#: lib/mv_web/live/components/sort_header_component.ex:55
#, elixir-autogen, elixir-format
msgid "ascending"
msgstr ""
#: lib/mv_web/components/table_components.ex:30
#: lib/mv_web/live/components/sort_header_component.ex:56
#, elixir-autogen, elixir-format
msgid "descending"
msgstr ""
@ -607,4 +608,12 @@ msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:20
#, elixir-autogen, elixir-format
msgid "Users"
#: lib/mv_web/live/components/sort_header_component.ex:60
#, elixir-autogen, elixir-format
msgid "Click to sort"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:53
#, elixir-autogen, elixir-format
msgid "First name"
msgstr ""

View file

@ -58,3 +58,6 @@ msgstr ""
msgid "Your password has successfully been reset"
msgstr ""
#~ msgid "Sign in with Rauthy"
#~ msgstr "Sign in with Vereinscloud"

View file

@ -16,7 +16,7 @@ msgstr ""
msgid "Actions"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:84
#: lib/mv_web/live/member_live/index.html.heex:193
#: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format
msgid "Are you sure?"
@ -29,19 +29,19 @@ msgid "Attempting to reconnect"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:25
#: lib/mv_web/live/member_live/index.html.heex:69
#: lib/mv_web/live/member_live/show.ex:37
#: lib/mv_web/live/member_live/index.html.heex:138
#: lib/mv_web/live/member_live/show.ex:36
#, elixir-autogen, elixir-format
msgid "City"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:86
#: lib/mv_web/live/member_live/index.html.heex:195
#: lib/mv_web/live/user_live/index.html.heex:67
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:78
#: lib/mv_web/live/member_live/index.html.heex:187
#: lib/mv_web/live/user_live/form.ex:109
#: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format
@ -55,8 +55,8 @@ msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:18
#: lib/mv_web/live/member_live/index.html.heex:65
#: lib/mv_web/live/member_live/show.ex:28
#: lib/mv_web/live/member_live/index.html.heex:70
#: lib/mv_web/live/member_live/show.ex:27
#: lib/mv_web/live/user_live/form.ex:14
#: lib/mv_web/live/user_live/index.html.heex:44
#: lib/mv_web/live/user_live/show.ex:25
@ -71,8 +71,8 @@ msgid "First Name"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:22
#: lib/mv_web/live/member_live/index.html.heex:71
#: lib/mv_web/live/member_live/show.ex:34
#: lib/mv_web/live/member_live/index.html.heex:172
#: lib/mv_web/live/member_live/show.ex:33
#, elixir-autogen, elixir-format
msgid "Join Date"
msgstr ""
@ -88,7 +88,7 @@ msgstr ""
msgid "New Member"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:75
#: lib/mv_web/live/member_live/index.html.heex:184
#: lib/mv_web/live/user_live/index.html.heex:56
#, elixir-autogen, elixir-format
msgid "Show"
@ -128,8 +128,8 @@ msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:27
#: lib/mv_web/live/member_live/index.html.heex:67
#: lib/mv_web/live/member_live/show.ex:39
#: lib/mv_web/live/member_live/index.html.heex:104
#: lib/mv_web/live/member_live/show.ex:38
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr ""
@ -147,15 +147,15 @@ msgid "Paid"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:21
#: lib/mv_web/live/member_live/index.html.heex:70
#: lib/mv_web/live/member_live/show.ex:33
#: lib/mv_web/live/member_live/index.html.heex:155
#: lib/mv_web/live/member_live/show.ex:32
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:28
#: lib/mv_web/live/member_live/index.html.heex:68
#: lib/mv_web/live/member_live/show.ex:40
#: lib/mv_web/live/member_live/index.html.heex:121
#: lib/mv_web/live/member_live/show.ex:39
#, elixir-autogen, elixir-format
msgid "Postal Code"
msgstr ""
@ -174,8 +174,8 @@ msgid "Saving..."
msgstr ""
#: lib/mv_web/live/member_live/form.ex:26
#: lib/mv_web/live/member_live/index.html.heex:66
#: lib/mv_web/live/member_live/show.ex:38
#: lib/mv_web/live/member_live/index.html.heex:87
#: lib/mv_web/live/member_live/show.ex:37
#, elixir-autogen, elixir-format
msgid "Street"
msgstr ""
@ -318,14 +318,13 @@ msgstr ""
msgid "Member"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:19
#: lib/mv_web/live/member_live/index.ex:14
#: lib/mv_web/components/layouts/navbar.ex:14
#: lib/mv_web/live/member_live/index.ex:8
#: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format
msgid "Members"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:57
#: lib/mv_web/live/property_type_live/form.ex:16
#, elixir-autogen, elixir-format
msgid "Name"
@ -470,11 +469,13 @@ msgid "Value type"
msgstr ""
#: lib/mv_web/components/table_components.ex:30
#: lib/mv_web/live/components/sort_header_component.ex:55
#, elixir-autogen, elixir-format
msgid "ascending"
msgstr ""
#: lib/mv_web/components/table_components.ex:30
#: lib/mv_web/live/components/sort_header_component.ex:56
#, elixir-autogen, elixir-format
msgid "descending"
msgstr ""
@ -554,57 +555,17 @@ msgstr "Set Password"
msgid "User will be created without a password. Check 'Set Password' to add one."
msgstr "User will be created without a password. Check 'Set Password' to add one."
#: lib/mv_web/live/user_live/show.ex:30
#: lib/mv_web/live/components/sort_header_component.ex:60
#, elixir-autogen, elixir-format
msgid "Click to sort"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:53
#, elixir-autogen, elixir-format, fuzzy
msgid "Linked Member"
msgid "First name"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:41
#, elixir-autogen, elixir-format
msgid "Linked User"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:40
#, elixir-autogen, elixir-format
msgid "No member linked"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:51
#, elixir-autogen, elixir-format
msgid "No user linked"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:14
#: lib/mv_web/live/member_live/show.ex:16
#, elixir-autogen, elixir-format
msgid "Back to members list"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:13
#: lib/mv_web/live/user_live/show.ex:15
#, elixir-autogen, elixir-format
msgid "Back to users list"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:27
#: lib/mv_web/components/layouts/navbar.ex:33
#, elixir-autogen, elixir-format, fuzzy
msgid "Select language"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:40
#: lib/mv_web/components/layouts/navbar.ex:60
#, elixir-autogen, elixir-format
msgid "Toggle dark mode"
msgstr ""
#: lib/mv_web/live/components/search_bar_component.ex:15
#: lib/mv_web/live/member_live/index.html.heex:15
#, elixir-autogen, elixir-format
msgid "Search..."
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:20
#, elixir-autogen, elixir-format, fuzzy
msgid "Users"
msgstr ""
#~ #: lib/mv_web/auth_overrides.ex:30
#~ #, elixir-autogen, elixir-format
#~ msgid "or"
#~ msgstr ""

View file

@ -88,6 +88,18 @@ for member_attrs <- [
city: "Berlin",
street: "Kastanienallee",
house_number: "8"
},
%{
first_name: "Marianne",
last_name: "Wagner",
email: "marianne.wagner@example.de",
birth_date: ~D[1978-11-08],
join_date: ~D[2022-11-10],
paid: true,
phone_number: "+49301122334",
city: "Berlin",
street: "Kastanienallee",
house_number: "8"
}
] do
# Use upsert to prevent duplicates based on email

View file

@ -18,14 +18,14 @@ defmodule MvWeb.Components.SearchBarComponentTest do
html =
view
|> element("form[role=search]")
|> render_change(%{"query" => "Friedrich"})
|> render_submit(%{"query" => "Friedrich"})
refute html =~ "Greta"
html =
view
|> element("form[role=search]")
|> render_change(%{"query" => "Greta"})
|> render_submit(%{"query" => "Greta"})
refute html =~ "Friedrich"
end

View file

@ -0,0 +1,319 @@
defmodule MvWeb.Components.SortHeaderComponentTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
describe "rendering" do
test "renders with correct attributes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Test that the component renders with correct attributes
assert has_element?(view, "[data-testid='first_name']")
assert has_element?(view, "button[phx-value-field='city']")
assert has_element?(view, "button[phx-value-field='first_name']", "First name")
end
test "renders all sortable headers", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
sortable_fields = [
:first_name,
:email,
:street,
:house_number,
:postal_code,
:city,
:phone_number,
:join_date
]
for field <- sortable_fields do
assert has_element?(view, "button[phx-value-field='#{field}']")
end
end
test "renders correct labels", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Test specific labels
assert has_element?(view, "button[phx-value-field='first_name']", "First name")
assert has_element?(view, "button[phx-value-field='email']", "Email")
assert has_element?(view, "button[phx-value-field='city']", "City")
end
end
describe "sort icons" do
test "shows neutral icon for specific field when not sorted", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# The neutral icon has the opcity class we can test for
# Test that EMAIL field specifically shows neutral icon
assert has_element?(view, "[data-testid='email'] .opacity-40")
# Test that CITY field specifically shows neutral icon
assert has_element?(view, "[data-testid='city'] .opacity-40")
end
test "shows ascending icon for specific field when sorted ascending", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?query=&sort_field=city&sort_order=asc")
# Test that FIRST_NAME field specifically shows ascending icon
# Test CSS classes - no opacity for active state
refute has_element?(view, "[data-testid='city'] .opacity-40")
# Test that OTHER fields still show neutral icons
assert has_element?(view, "[data-testid='first_name'] .opacity-40")
# Test HTML content - should contain chevronup AND chevron up down
assert html =~ "hero-chevron-up"
assert html =~ "hero-chevron-up-down"
# Count occurrences to ensure only one ascending icon
up_count = html |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
# Should be exactly one chevronup icon
assert up_count == 1
end
test "shows descending icon for specific field when sorted descending", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
# Count occurrences to ensure only one descending icon
down_count = html |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
# Should be exactly one chevrondown icon
assert down_count == 1
end
test "multiple fields can have different icon states", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=&sort_field=city&sort_order=asc")
# CITY field should be active (ascending)
refute has_element?(view, "[data-testid='city'] .opacity-40")
# All other fields should be neutral
assert has_element?(view, "[data-testid='first_name'] .opacity-40")
assert has_element?(view, "[data-testid='email'] .opacity-40")
assert has_element?(view, "[data-testid='street'] .opacity-40")
assert has_element?(view, "[data-testid='house_number'] .opacity-40")
assert has_element?(view, "[data-testid='postal_code'] .opacity-40")
assert has_element?(view, "[data-testid='phone_number'] .opacity-40")
assert has_element?(view, "[data-testid='join_date'] .opacity-40")
end
test "icon state changes correctly when clicking different fields", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Start: all fields neutral except first name as default
assert has_element?(view, "[data-testid='city'] .opacity-40")
refute has_element?(view, "[data-testid='first_name'] .opacity-40")
assert has_element?(view, "[data-testid='email'] .opacity-40")
# Click city - should become active
view
|> element("button[phx-value-field='city']")
|> render_click()
# city should be active, email should still be neutral
refute has_element?(view, "[data-testid='city'] .opacity-40")
assert has_element?(view, "[data-testid='email'] .opacity-40")
# Click email - should switch active field
view
|> element("button[phx-value-field='email']")
|> render_click()
# email should be active, city should be neutral again
refute has_element?(view, "[data-testid='email'] .opacity-40")
assert has_element?(view, "[data-testid='city'] .opacity-40")
end
test "specific field shows correct icon for each sort state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Test EMAIL field specifically
{:ok, view, html_asc} = live(conn, "/members?sort_field=email&sort_order=asc")
assert html_asc =~ "hero-chevron-up"
refute has_element?(view, "[data-testid='email'] .opacity-40")
{:ok, view, html_desc} = live(conn, "/members?sort_field=email&sort_order=desc")
assert html_desc =~ "hero-chevron-down"
refute has_element?(view, "[data-testid='email'] .opacity-40")
{:ok, view, html_neutral} = live(conn, "/members")
assert html_neutral =~ "hero-chevron-up-down"
assert has_element?(view, "[data-testid='email'] .opacity-40")
end
test "icon distribution is correct for all fields", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Test neutral state - all fields except first name (default) should show neutral icons
{:ok, _view, html_neutral} = live(conn, "/members")
# Count neutral icons (should be 7 - one for each field)
neutral_count =
html_neutral |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1)
assert neutral_count == 7
# Count active icons (should be 1)
up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
down_count = html_neutral |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
assert up_count == 1
assert down_count == 0
# Test ascending state - one field active, others neutral
{:ok, _view, html_asc} = live(conn, "/members?sort_field=first_name&sort_order=asc")
# Should have exactly 1 ascending icon and 7 neutral icons
up_count = html_asc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
neutral_count = html_asc |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1)
down_count = html_asc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
assert up_count == 1
assert neutral_count == 7
assert down_count == 0
end
end
describe "accessibility" do
test "sets aria-label correctly for unsorted state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Check aria-label for unsorted state
assert has_element?(view, "button[phx-value-field='city'][aria-label='Click to sort']")
end
test "sets aria-label correctly for ascending sort", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc")
# Check aria-label for ascending sort
assert has_element?(view, "button[phx-value-field='first_name'][aria-label='ascending']")
end
test "sets aria-label correctly for descending sort", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=desc")
# Check aria-label for descending sort
assert has_element?(view, "button[phx-value-field='first_name'][aria-label='descending']")
end
test "includes tooltip with correct aria-label", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc")
# Check that tooltip div exists with correct data-tip
assert has_element?(view, "[data-testid='first_name']")
assert has_element?(view, "button[phx-value-field='first_name'][aria-label='ascending']")
end
test "aria-labels work for all sortable fields", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=email&sort_order=desc")
# Test aria-labels for different fields
assert has_element?(view, "button[phx-value-field='email'][aria-label='descending']")
assert has_element?(
view,
"button[phx-value-field='first_name'][aria-label='Click to sort']"
)
assert has_element?(view, "button[phx-value-field='city'][aria-label='Click to sort']")
end
end
describe "component behavior" do
test "clicking sends sort message to parent", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Click on the first name sort header
view
|> element("button[phx-value-field='first_name']")
|> render_click()
# The component should send a message to the parent LiveView
# This is tested indirectly through the URL change in integration tests
end
test "component handles different field types correctly", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Test that different field types render correctly
assert has_element?(view, "button[phx-value-field='first_name']")
assert has_element?(view, "button[phx-value-field='email']")
assert has_element?(view, "button[phx-value-field='join_date']")
end
end
describe "edge cases" do
test "handles invalid sort field gracefully", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?sort_field=invalid_field&sort_order=asc")
# Should not crash and should default sorting for first name
assert html =~ "hero-chevron-up-down"
refute has_element?(view, "[data-testid='first_name'] .opacity-40")
end
test "handles invalid sort order gracefully", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?sort_field=first_name&sort_order=invalid")
# Should default to ascending
assert html =~ "hero-chevron-up"
refute has_element?(view, "[data-testid='first_name'] [aria-label='ascending']")
end
test "handles empty sort parameters", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?sort_field=&sort_order=")
# Should show neutral icons
assert html =~ "hero-chevron-up-down"
assert has_element?(view, "[data-testid='city'] .opacity-40")
end
end
describe "icon state transitions" do
test "icon changes when sorting state changes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Start with neutral state
assert has_element?(view, "[data-testid='city'] .opacity-40")
# Click to sort ascending
view
|> element("button[phx-value-field='city']")
|> render_click()
# Should now be ascending (no opacity class)
refute has_element?(view, "[data-testid='city'] .opacity-40")
end
test "multiple fields can be tested for icon states", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?sort_field=email&sort_order=desc")
# Email should be active (descending)
assert html =~ "hero-chevron-down"
refute has_element?(view, "[data-testid='email'] .opacity-40")
# Other fields should be neutral
assert has_element?(view, "[data-testid='first_name'] .opacity-40")
assert has_element?(view, "[data-testid='city'] .opacity-40")
end
end
end

View file

@ -56,7 +56,6 @@ defmodule MvWeb.MemberLive.IndexTest do
test "shows translated flash message after creating a member in English", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = Plug.Test.init_test_session(conn, locale: "en")
{:ok, form_view, _html} = live(conn, "/members/new")
form_data = %{
@ -75,6 +74,143 @@ defmodule MvWeb.MemberLive.IndexTest do
assert has_element?(index_view, "#flash-group", "Member create successfully")
end
describe "sorting integration" do
test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# The component data test ids are built with the name of the field
# First click should sort ASC
view
|> element("[data-testid='email']")
|> render_click()
# The LiveView pushes a patch with the new query params
assert_patch(view, "/members?query=&sort_field=email&sort_order=asc")
# Second click toggles to DESC
view
|> element("[data-testid='email']")
|> render_click()
assert_patch(view, "/members?query=&sort_field=email&sort_order=desc")
end
test "clicking different column header resets order to ascending", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=email&sort_order=desc")
# Click on a different column
view
|> element("[data-testid='first_name']")
|> render_click()
assert_patch(view, "/members?query=&sort_field=first_name&sort_order=asc")
end
test "all sortable columns work correctly", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# default ascending sorting with first name
assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']")
sortable_fields = [
:email,
:street,
:house_number,
:postal_code,
:city,
:phone_number,
:join_date
]
for field <- sortable_fields do
view
|> element("[data-testid='#{field}']")
|> render_click()
assert_patch(view, "/members?query=&sort_field=#{field}&sort_order=asc")
end
end
test "sorting works with search query", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=test")
view
|> element("[data-testid='email']")
|> render_click()
assert_patch(view, "/members?query=test&sort_field=email&sort_order=asc")
end
test "sorting maintains search query when toggling order", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=asc")
view
|> element("[data-testid='email']")
|> render_click()
assert_patch(view, "/members?query=test&sort_field=email&sort_order=desc")
end
end
describe "URL param handling" do
test "handle_params reads sort query and applies it", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
# Check that the sort state is correctly applied
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
end
test "handle_params handles invalid sort field gracefully", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?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']")
end
test "handle_params preserves search query with sort params", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=desc")
# Both search and sort should be preserved
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
end
end
describe "search and sort integration" do
test "search maintains sort state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
# Perform search
view
|> element("[data-testid='search-input']")
|> render_change(%{value: "test"})
# Sort state should be maintained
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
end
test "sort maintains search state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=asc")
# Perform sort
view
|> element("[data-testid='email']")
|> render_click()
# Search state should be maintained
assert_patch(view, "/members?query=test&sort_field=email&sort_order=desc")
end
end
test "handle_info(:search_changed) updates assigns with search results", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")