Sorting header for members list closes #152 #175 #166

Merged
moritz merged 11 commits from feature/152_sorting_default_fields into main 2025-10-30 16:44:50 +01:00
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
carla marked this conversation as resolved Outdated

ambiguous Unicode character - in comment.

ambiguous Unicode character `-` in comment.
- label: string() # Column Heading (can be an heex template)
carla marked this conversation as resolved Outdated

typo templyte

typo `templyte`
- 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
carla marked this conversation as resolved Outdated

ambiguous Unicode character - in comment.

ambiguous Unicode character - in comment.
# -------------------------------------------------
# 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}
moritz marked this conversation as resolved Outdated

https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html
"The life-cycle is: mount/3 -> handle_params/3 -> render/1"
Therefore handle_params is called twice, in your mount/3 and afterwards by the LiveView. Each handdle_params loads all the members. Is this line necessary at all? If I just remove it it still works for me.

https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html "The life-cycle is: mount/3 -> handle_params/3 -> render/1" Therefore handle_params is called twice, in your mount/3 and afterwards by the LiveView. Each handdle_params loads all the members. Is this line necessary at all? If I just remove it it still works for me.
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
moritz marked this conversation as resolved Outdated

@moritz wrote in #166 (comment):

If the rows are sorted by a specific column the column header shows a single arrow in the sorting direction. The other rows show a double arrow (up and down). If I sort for another column the double arrow switches into a single arrow, but the previous column header arrow keeps the same. So it's not possible to see which column is the column sorted by. It would be good to reset the sorting arrows of the previous columns. Maybe something more obvious to highlight which is the current sort column would be nice too.

maybe something like this could work to reset the old header component:

   old_id = :"sort_#{old_field}"

    # Update the SortHeader to
    send_update(MvWeb.Components.SortHeaderComponent,
      id: old_id,
      sort_field: new_field,
      sort_order: new_order
    )
@moritz wrote in https://git.local-it.org/local-it/mitgliederverwaltung/pulls/166#issuecomment-13801: > If the rows are sorted by a specific column the column header shows a single arrow in the sorting direction. The other rows show a double arrow (up and down). If I sort for another column the double arrow switches into a single arrow, but the previous column header arrow keeps the same. So it's not possible to see which column is the column sorted by. It would be good to reset the sorting arrows of the previous columns. Maybe something more obvious to highlight which is the current sort column would be nice too. maybe something like this could work to reset the old header component: ``` old_id = :"sort_#{old_field}" # Update the SortHeader to send_update(MvWeb.Components.SortHeaderComponent, id: old_id, sort_field: new_field, sort_order: new_order ) ```
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
)
moritz marked this conversation as resolved Outdated

Best practice: instead of hardcoded paths, render path dynamically:

current_path = socket.view.__live__() |> elem(0)
new_path = current_path <> "?" <> URI.encode_query(query_params)

Also the ~p sigil could be used for creating a path.

Best practice: instead of hardcoded paths, render path dynamically: ``` current_path = socket.view.__live__() |> elem(0) new_path = current_path <> "?" <> URI.encode_query(query_params) ``` Also the `~p` sigil could be used for creating a path.

I tried that already before and did not find a simple solution for getting the current path. So I would keep that static for now.

I tried that already before and did not find a simple solution for getting the current path. So I would keep that static for now.
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}"
moritz marked this conversation as resolved Outdated

This function ignores the sorting. This leads to reset the sorting while searching.
To avoid duplicate code I would recommend to integrate the search logic into load_members.

This function ignores the sorting. This leads to reset the sorting while searching. To avoid duplicate code I would recommend to integrate the search logic into `load_members`.
# 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']")
moritz marked this conversation as resolved Outdated

Maybe some more tests like:

  • Test that all sortable headers exist
  • Test initial state shows correct icon
  • Test aria-label
Maybe some more tests like: - Test that all sortable headers exist - Test initial state shows correct icon - Test aria-label
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
moritz marked this conversation as resolved Outdated

ambiguous Unicode character - in comment.

ambiguous Unicode character `-` in comment.
# 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")
moritz marked this conversation as resolved Outdated

ambiguous Unicode character - in comment.

ambiguous Unicode character `-` in comment.
# 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")