From 671e6ce804519662958a10a7202436e79a541067 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 2 Dec 2025 13:40:17 +0100 Subject: [PATCH] feat: add payment status filter and paid column to member list Add PaymentFilterComponent dropdown and colored paid column. Filter supports URL bookmarking and combines with search/sort. --- .../components/payment_filter_component.ex | 140 +++++++++++ lib/mv_web/live/member_live/index.ex | 127 ++++++++-- lib/mv_web/live/member_live/index.html.heex | 28 ++- priv/gettext/de/LC_MESSAGES/default.po | 65 ++++-- priv/gettext/default.pot | 65 ++++-- priv/gettext/en/LC_MESSAGES/default.po | 65 ++++-- .../payment_filter_component_test.exs | 182 +++++++++++++++ .../index_custom_fields_display_test.exs | 3 +- test/mv_web/member_live/index_test.exs | 217 ++++++++++++++++++ 9 files changed, 814 insertions(+), 78 deletions(-) create mode 100644 lib/mv_web/live/components/payment_filter_component.ex create mode 100644 test/mv_web/components/payment_filter_component_test.exs diff --git a/lib/mv_web/live/components/payment_filter_component.ex b/lib/mv_web/live/components/payment_filter_component.ex new file mode 100644 index 0000000..ade80b9 --- /dev/null +++ b/lib/mv_web/live/components/payment_filter_component.ex @@ -0,0 +1,140 @@ +defmodule MvWeb.Components.PaymentFilterComponent do + @moduledoc """ + Provides the PaymentFilter Live-Component. + + A dropdown filter for filtering members by payment status (paid/not paid/all). + Uses DaisyUI dropdown styling and sends filter changes to parent LiveView. + + ## Props + - `:paid_filter` - Current filter state: `nil` (all), `:paid`, or `:not_paid` + - `:id` - Component ID (required) + - `:member_count` - Number of filtered members to display in badge (optional, default: 0) + + ## Events + - Sends `{:payment_filter_changed, filter}` to parent when filter changes + """ + use MvWeb, :live_component + + @impl true + def mount(socket) do + {:ok, assign(socket, :open, false)} + end + + @impl true + def update(assigns, socket) do + socket = + socket + |> assign(:id, assigns.id) + |> assign(:paid_filter, assigns[:paid_filter]) + |> assign(:member_count, assigns[:member_count] || 0) + + {:ok, socket} + end + + @impl true + def render(assigns) do + ~H""" +
+ + + +
+ """ + end + + @impl true + def handle_event("toggle_dropdown", _params, socket) do + {:noreply, assign(socket, :open, !socket.assigns.open)} + end + + @impl true + def handle_event("close_dropdown", _params, socket) do + {:noreply, assign(socket, :open, false)} + end + + @impl true + def handle_event("select_filter", %{"filter" => filter_str}, socket) do + filter = parse_filter(filter_str) + + # Close dropdown and notify parent + socket = assign(socket, :open, false) + send(self(), {:payment_filter_changed, filter}) + + {:noreply, socket} + end + + # Parse filter string to atom + defp parse_filter("paid"), do: :paid + defp parse_filter("not_paid"), do: :not_paid + defp parse_filter(_), do: nil + + # Get display label for current filter + defp filter_label(nil), do: gettext("All") + defp filter_label(:paid), do: gettext("Paid") + defp filter_label(:not_paid), do: gettext("Not paid") +end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 4d444b9..04cabdc 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -46,7 +46,7 @@ defmodule MvWeb.MemberLive.Index do Initializes the LiveView state. Sets up initial assigns for page title, search query, sort configuration, - and member selection. Actual data loading happens in `handle_params/3`. + payment filter, and member selection. Actual data loading happens in `handle_params/3`. """ @impl true def mount(_params, _session, socket) do @@ -74,6 +74,7 @@ defmodule MvWeb.MemberLive.Index do |> assign(:query, "") |> assign_new(:sort_field, fn -> :first_name end) |> assign_new(:sort_order, fn -> :asc end) + |> assign(:paid_filter, nil) |> assign(:selected_members, MapSet.new()) |> assign(:custom_fields_visible, custom_fields_visible) |> assign(:member_fields_visible, get_visible_member_fields(settings)) @@ -213,11 +214,8 @@ defmodule MvWeb.MemberLive.Index do 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 - } + query_params = + build_query_params(q, existing_field_query, existing_sort_query, socket.assigns.paid_filter) # Set the new path with params new_path = ~p"/members?#{query_params}" @@ -230,13 +228,38 @@ defmodule MvWeb.MemberLive.Index do )} end + @impl true + def handle_info({:payment_filter_changed, filter}, socket) do + socket = + socket + |> assign(:paid_filter, filter) + |> load_members(socket.assigns.query) + + # Build the URL with all params including new filter + query_params = + build_query_params( + socket.assigns.query, + socket.assigns.sort_field, + socket.assigns.sort_order, + filter + ) + + new_path = ~p"/members?#{query_params}" + + {:noreply, + push_patch(socket, + to: new_path, + replace: true + )} + end + # ----------------------------------------------------------------- # Handle Params from the URL # ----------------------------------------------------------------- @doc """ Handles URL parameter changes. - Parses query parameters for search query, sort field, and sort order, + Parses query parameters for search query, sort field, sort order, and payment filter, then loads members accordingly. This enables bookmarkable URLs and browser back/forward navigation. """ @@ -246,6 +269,7 @@ defmodule MvWeb.MemberLive.Index do socket |> maybe_update_search(params) |> maybe_update_sort(params) + |> maybe_update_paid_filter(params) |> load_members(params["query"]) |> prepare_dynamic_cols() @@ -337,11 +361,13 @@ defmodule MvWeb.MemberLive.Index do field end - query_params = %{ - "query" => socket.assigns.query, - "sort_field" => field_str, - "sort_order" => Atom.to_string(order) - } + query_params = + build_query_params( + socket.assigns.query, + field_str, + Atom.to_string(order), + socket.assigns.paid_filter + ) new_path = ~p"/members?#{query_params}" @@ -352,13 +378,45 @@ defmodule MvWeb.MemberLive.Index do )} end - # Loads members from the database with custom field values and applies search/sort filters. + # Builds URL query parameters map including all filter/sort state. + # Converts paid_filter atom to string for URL. + defp build_query_params(query, sort_field, sort_order, paid_filter) do + field_str = + if is_atom(sort_field) do + Atom.to_string(sort_field) + else + sort_field + end + + order_str = + if is_atom(sort_order) do + Atom.to_string(sort_order) + else + sort_order + end + + base_params = %{ + "query" => query, + "sort_field" => field_str, + "sort_order" => order_str + } + + # Only add paid_filter to URL if it's set + case paid_filter do + nil -> base_params + :paid -> Map.put(base_params, "paid_filter", "paid") + :not_paid -> Map.put(base_params, "paid_filter", "not_paid") + end + end + + # Loads members from the database with custom field values and applies search/sort/payment filters. # # Process: # 1. Builds base query with selected fields # 2. Loads custom field values for visible custom fields (filtered at database level) # 3. Applies search filter if provided - # 4. Applies sorting (database-level for regular fields, in-memory for custom fields) + # 4. Applies payment status filter if set + # 5. Applies sorting (database-level for regular fields, in-memory for custom fields) # # Performance Considerations: # - Database-level filtering: Custom field values are filtered directly in the database @@ -383,6 +441,9 @@ defmodule MvWeb.MemberLive.Index do # Apply the search filter first query = apply_search_filter(query, search_query) + # Apply payment status filter + query = apply_paid_filter(query, socket.assigns.paid_filter) + # Apply sorting based on current socket state # For custom fields, we sort after loading {query, sort_after_load} = @@ -457,6 +518,24 @@ defmodule MvWeb.MemberLive.Index do end end + # Applies payment status filter to the query. + # + # Filter values: + # - nil: No filter, return all members + # - :paid: Only members with paid == true + # - :not_paid: Members with paid == false or paid == nil (not paid) + defp apply_paid_filter(query, nil), do: query + + defp apply_paid_filter(query, :paid) do + Ash.Query.filter(query, expr(paid == true)) + end + + defp apply_paid_filter(query, :not_paid) do + # Include both false and nil as "not paid" + # Note: paid != true doesn't work correctly with NULL values in SQL + Ash.Query.filter(query, expr(paid == false or is_nil(paid))) + end + # Functions to toggle sorting order defp toggle_order(:asc), do: :desc defp toggle_order(:desc), do: :asc @@ -747,6 +826,26 @@ defmodule MvWeb.MemberLive.Index do socket end + # Updates paid filter from URL parameters if present. + # + # Validates the filter value, falling back to nil (no filter) if invalid. + defp maybe_update_paid_filter(socket, %{"paid_filter" => filter_str}) do + filter = determine_paid_filter(filter_str) + assign(socket, :paid_filter, filter) + end + + defp maybe_update_paid_filter(socket, _params) do + # Reset filter if not in URL params + assign(socket, :paid_filter, nil) + end + + # Determines valid paid filter from URL parameter. + # + # Only accepts "paid" or "not_paid", falls back to nil for invalid values. + defp determine_paid_filter("paid"), do: :paid + defp determine_paid_filter("not_paid"), do: :not_paid + defp determine_paid_filter(_), do: nil + # ------------------------------------------------------------- # Helper Functions for Custom Field Values # ------------------------------------------------------------- diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 55b0a20..58e22b6 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -26,12 +26,20 @@ - <.live_component - module={MvWeb.Components.SearchBarComponent} - id="search-bar" - query={@query} - placeholder={gettext("Search...")} - /> +
+ <.live_component + module={MvWeb.Components.SearchBarComponent} + id="search-bar" + query={@query} + placeholder={gettext("Search...")} + /> + <.live_component + module={MvWeb.Components.PaymentFilterComponent} + id="payment-filter" + paid_filter={@paid_filter} + member_count={length(@members)} + /> +
<.table id="members" @@ -213,6 +221,14 @@ > {member.join_date} + <:col :let={member} label={gettext("Paid")}> + + {if member.paid == true, do: gettext("Yes"), else: gettext("No")} + + <:action :let={member}>
<.link navigate={~p"/members/#{member}"}>{gettext("Show")} diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 68afafc..a1bf071 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -15,7 +15,7 @@ msgstr "" msgid "Actions" msgstr "Aktionen" -#: lib/mv_web/live/member_live/index.html.heex:227 +#: lib/mv_web/live/member_live/index.html.heex:243 #: lib/mv_web/live/user_live/index.html.heex:72 #, 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:54 -#: lib/mv_web/live/member_live/index.html.heex:171 +#: lib/mv_web/live/member_live/index.html.heex:179 #: lib/mv_web/live/member_live/show.ex:59 #, elixir-autogen, elixir-format msgid "City" msgstr "Stadt" -#: lib/mv_web/live/member_live/index.html.heex:229 +#: lib/mv_web/live/member_live/index.html.heex:245 #: lib/mv_web/live/user_live/index.html.heex:74 #, elixir-autogen, elixir-format msgid "Delete" msgstr "Löschen" -#: lib/mv_web/live/member_live/index.html.heex:221 +#: lib/mv_web/live/member_live/index.html.heex:237 #: lib/mv_web/live/user_live/form.ex:265 #: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format @@ -54,7 +54,7 @@ msgid "Edit Member" msgstr "Mitglied bearbeiten" #: lib/mv_web/live/member_live/form.ex:47 -#: lib/mv_web/live/member_live/index.html.heex:99 +#: lib/mv_web/live/member_live/index.html.heex:107 #: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/index.html.heex:44 @@ -70,7 +70,7 @@ msgid "First Name" msgstr "Vorname" #: lib/mv_web/live/member_live/form.ex:51 -#: lib/mv_web/live/member_live/index.html.heex:207 +#: lib/mv_web/live/member_live/index.html.heex:215 #: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" @@ -87,7 +87,7 @@ msgstr "Nachname" msgid "New Member" msgstr "Neues Mitglied" -#: lib/mv_web/live/member_live/index.html.heex:218 +#: lib/mv_web/live/member_live/index.html.heex:234 #: lib/mv_web/live/user_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Show" @@ -121,7 +121,7 @@ msgid "Exit Date" msgstr "Austrittsdatum" #: lib/mv_web/live/member_live/form.ex:56 -#: lib/mv_web/live/member_live/index.html.heex:135 +#: lib/mv_web/live/member_live/index.html.heex:143 #: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "House Number" @@ -133,21 +133,24 @@ msgstr "Hausnummer" msgid "Notes" msgstr "Notizen" +#: lib/mv_web/live/components/payment_filter_component.ex:88 +#: lib/mv_web/live/components/payment_filter_component.ex:138 #: lib/mv_web/live/member_live/form.ex:49 +#: lib/mv_web/live/member_live/index.html.heex:224 #: lib/mv_web/live/member_live/show.ex:52 #, elixir-autogen, elixir-format msgid "Paid" msgstr "Bezahlt" #: lib/mv_web/live/member_live/form.ex:50 -#: lib/mv_web/live/member_live/index.html.heex:189 +#: lib/mv_web/live/member_live/index.html.heex:197 #: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "Telefonnummer" #: lib/mv_web/live/member_live/form.ex:57 -#: lib/mv_web/live/member_live/index.html.heex:153 +#: lib/mv_web/live/member_live/index.html.heex:161 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -168,7 +171,7 @@ msgid "Saving..." msgstr "Speichern..." #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:117 +#: lib/mv_web/live/member_live/index.html.heex:125 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -179,6 +182,7 @@ msgstr "Straße" msgid "Id" msgstr "ID" +#: lib/mv_web/live/member_live/index.html.heex:229 #: lib/mv_web/live/member_live/index/formatter.ex:61 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format @@ -195,6 +199,7 @@ msgstr "Mitglied anzeigen" msgid "This is a member record from your database." msgstr "Dies ist ein Mitglied aus deiner Datenbank." +#: lib/mv_web/live/member_live/index.html.heex:229 #: lib/mv_web/live/member_live/index/formatter.ex:60 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format @@ -360,12 +365,12 @@ msgstr "Profil" msgid "Required" msgstr "Erforderlich" -#: lib/mv_web/live/member_live/index.html.heex:55 +#: lib/mv_web/live/member_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "Alle Mitglieder auswählen" -#: lib/mv_web/live/member_live/index.html.heex:69 +#: lib/mv_web/live/member_live/index.html.heex:77 #, elixir-autogen, elixir-format msgid "Select member" msgstr "Mitglied auswählen" @@ -551,7 +556,7 @@ msgid "Toggle dark mode" msgstr "Dunklen Modus umschalten" #: lib/mv_web/live/components/search_bar_component.ex:15 -#: lib/mv_web/live/member_live/index.html.heex:33 +#: lib/mv_web/live/member_live/index.html.heex:34 #, elixir-autogen, elixir-format msgid "Search..." msgstr "Suchen..." @@ -567,7 +572,7 @@ msgstr "Benutzer*innen" msgid "Click to sort" msgstr "Klicke um zu sortieren" -#: lib/mv_web/live/member_live/index.html.heex:81 +#: lib/mv_web/live/member_live/index.html.heex:89 #, elixir-autogen, elixir-format msgid "First name" msgstr "Vorname" @@ -777,7 +782,7 @@ msgstr "Mitglied entverknüpfen" msgid "Unlinking scheduled" msgstr "Entverknüpfung geplant" -#: lib/mv_web/live/member_live/index.ex:164 +#: lib/mv_web/live/member_live/index.ex:165 #, elixir-autogen, elixir-format msgid "Copied %{count} email address to clipboard" msgid_plural "Copied %{count} email addresses to clipboard" @@ -794,12 +799,12 @@ msgstr "E-Mail-Adressen der ausgewählten Mitglieder kopieren" msgid "Copy emails" msgstr "E-Mails kopieren" -#: lib/mv_web/live/member_live/index.ex:153 +#: lib/mv_web/live/member_live/index.ex:154 #, elixir-autogen, elixir-format msgid "No email addresses found" msgstr "Keine E-Mail-Adressen gefunden" -#: lib/mv_web/live/member_live/index.ex:150 +#: lib/mv_web/live/member_live/index.ex:151 #, elixir-autogen, elixir-format msgid "No members selected" msgstr "Keine Mitglieder ausgewählt" @@ -814,7 +819,7 @@ msgstr "E-Mail-Programm mit BCC-Empfänger*innen öffnen" msgid "Open in email program" msgstr "Im E-Mail-Programm öffnen" -#: lib/mv_web/live/member_live/index.ex:173 +#: lib/mv_web/live/member_live/index.ex:174 #, elixir-autogen, elixir-format msgid "Tip: Paste email addresses into the BCC field for privacy compliance" msgstr "Tipp: E-Mail-Adressen ins BCC-Feld einfügen für Datenschutzkonformität" @@ -831,3 +836,25 @@ msgstr "Felder, die mit einem Sternchen (*) markiert sind, dürfen nicht leer bl #, elixir-autogen, elixir-format, fuzzy msgid "This field cannot be empty" msgstr "Dieses Feld darf nicht leer bleiben" + +#: lib/mv_web/live/components/payment_filter_component.ex:74 +#: lib/mv_web/live/components/payment_filter_component.ex:137 +#, elixir-autogen, elixir-format +msgid "All" +msgstr "Alle" + +#: lib/mv_web/live/components/payment_filter_component.ex:48 +#, elixir-autogen, elixir-format +msgid "Filter by payment status" +msgstr "Nach Zahlungsstatus filtern" + +#: lib/mv_web/live/components/payment_filter_component.ex:102 +#: lib/mv_web/live/components/payment_filter_component.ex:139 +#, elixir-autogen, elixir-format +msgid "Not paid" +msgstr "Nicht bezahlt" + +#: lib/mv_web/live/components/payment_filter_component.ex:59 +#, elixir-autogen, elixir-format +msgid "Payment filter" +msgstr "Zahlungsfilter" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 3dd41b5..f73d54a 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -16,7 +16,7 @@ msgstr "" msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:227 +#: lib/mv_web/live/member_live/index.html.heex:243 #: lib/mv_web/live/user_live/index.html.heex:72 #, 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:54 -#: lib/mv_web/live/member_live/index.html.heex:171 +#: lib/mv_web/live/member_live/index.html.heex:179 #: lib/mv_web/live/member_live/show.ex:59 #, elixir-autogen, elixir-format msgid "City" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:229 +#: lib/mv_web/live/member_live/index.html.heex:245 #: lib/mv_web/live/user_live/index.html.heex:74 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:221 +#: lib/mv_web/live/member_live/index.html.heex:237 #: lib/mv_web/live/user_live/form.ex:265 #: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format @@ -55,7 +55,7 @@ msgid "Edit Member" msgstr "" #: lib/mv_web/live/member_live/form.ex:47 -#: lib/mv_web/live/member_live/index.html.heex:99 +#: lib/mv_web/live/member_live/index.html.heex:107 #: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/index.html.heex:44 @@ -71,7 +71,7 @@ msgid "First Name" msgstr "" #: lib/mv_web/live/member_live/form.ex:51 -#: lib/mv_web/live/member_live/index.html.heex:207 +#: lib/mv_web/live/member_live/index.html.heex:215 #: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" @@ -88,7 +88,7 @@ msgstr "" msgid "New Member" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:218 +#: lib/mv_web/live/member_live/index.html.heex:234 #: lib/mv_web/live/user_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Show" @@ -122,7 +122,7 @@ msgid "Exit Date" msgstr "" #: lib/mv_web/live/member_live/form.ex:56 -#: lib/mv_web/live/member_live/index.html.heex:135 +#: lib/mv_web/live/member_live/index.html.heex:143 #: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "House Number" @@ -134,21 +134,24 @@ msgstr "" msgid "Notes" msgstr "" +#: lib/mv_web/live/components/payment_filter_component.ex:88 +#: lib/mv_web/live/components/payment_filter_component.ex:138 #: lib/mv_web/live/member_live/form.ex:49 +#: lib/mv_web/live/member_live/index.html.heex:224 #: lib/mv_web/live/member_live/show.ex:52 #, elixir-autogen, elixir-format msgid "Paid" msgstr "" #: lib/mv_web/live/member_live/form.ex:50 -#: lib/mv_web/live/member_live/index.html.heex:189 +#: lib/mv_web/live/member_live/index.html.heex:197 #: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "" #: lib/mv_web/live/member_live/form.ex:57 -#: lib/mv_web/live/member_live/index.html.heex:153 +#: lib/mv_web/live/member_live/index.html.heex:161 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -169,7 +172,7 @@ msgid "Saving..." msgstr "" #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:117 +#: lib/mv_web/live/member_live/index.html.heex:125 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -180,6 +183,7 @@ msgstr "" msgid "Id" msgstr "" +#: lib/mv_web/live/member_live/index.html.heex:229 #: lib/mv_web/live/member_live/index/formatter.ex:61 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format @@ -196,6 +200,7 @@ msgstr "" msgid "This is a member record from your database." msgstr "" +#: lib/mv_web/live/member_live/index.html.heex:229 #: lib/mv_web/live/member_live/index/formatter.ex:60 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format @@ -361,12 +366,12 @@ msgstr "" msgid "Required" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:55 +#: lib/mv_web/live/member_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:69 +#: lib/mv_web/live/member_live/index.html.heex:77 #, elixir-autogen, elixir-format msgid "Select member" msgstr "" @@ -552,7 +557,7 @@ msgid "Toggle dark mode" msgstr "" #: lib/mv_web/live/components/search_bar_component.ex:15 -#: lib/mv_web/live/member_live/index.html.heex:33 +#: lib/mv_web/live/member_live/index.html.heex:34 #, elixir-autogen, elixir-format msgid "Search..." msgstr "" @@ -568,7 +573,7 @@ msgstr "" msgid "Click to sort" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:81 +#: lib/mv_web/live/member_live/index.html.heex:89 #, elixir-autogen, elixir-format msgid "First name" msgstr "" @@ -778,7 +783,7 @@ msgstr "" msgid "Unlinking scheduled" msgstr "" -#: lib/mv_web/live/member_live/index.ex:164 +#: lib/mv_web/live/member_live/index.ex:165 #, elixir-autogen, elixir-format msgid "Copied %{count} email address to clipboard" msgid_plural "Copied %{count} email addresses to clipboard" @@ -795,12 +800,12 @@ msgstr "" msgid "Copy emails" msgstr "" -#: lib/mv_web/live/member_live/index.ex:153 +#: lib/mv_web/live/member_live/index.ex:154 #, elixir-autogen, elixir-format msgid "No email addresses found" msgstr "" -#: lib/mv_web/live/member_live/index.ex:150 +#: lib/mv_web/live/member_live/index.ex:151 #, elixir-autogen, elixir-format msgid "No members selected" msgstr "" @@ -815,7 +820,7 @@ msgstr "" msgid "Open in email program" msgstr "" -#: lib/mv_web/live/member_live/index.ex:173 +#: lib/mv_web/live/member_live/index.ex:174 #, elixir-autogen, elixir-format msgid "Tip: Paste email addresses into the BCC field for privacy compliance" msgstr "" @@ -832,3 +837,25 @@ msgstr "" #, elixir-autogen, elixir-format msgid "This field cannot be empty" msgstr "" + +#: lib/mv_web/live/components/payment_filter_component.ex:74 +#: lib/mv_web/live/components/payment_filter_component.ex:137 +#, elixir-autogen, elixir-format +msgid "All" +msgstr "" + +#: lib/mv_web/live/components/payment_filter_component.ex:48 +#, elixir-autogen, elixir-format +msgid "Filter by payment status" +msgstr "" + +#: lib/mv_web/live/components/payment_filter_component.ex:102 +#: lib/mv_web/live/components/payment_filter_component.ex:139 +#, elixir-autogen, elixir-format +msgid "Not paid" +msgstr "" + +#: lib/mv_web/live/components/payment_filter_component.ex:59 +#, elixir-autogen, elixir-format +msgid "Payment filter" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 125ce4e..12c17cd 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -16,7 +16,7 @@ msgstr "" msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:227 +#: lib/mv_web/live/member_live/index.html.heex:243 #: lib/mv_web/live/user_live/index.html.heex:72 #, 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:54 -#: lib/mv_web/live/member_live/index.html.heex:171 +#: lib/mv_web/live/member_live/index.html.heex:179 #: lib/mv_web/live/member_live/show.ex:59 #, elixir-autogen, elixir-format msgid "City" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:229 +#: lib/mv_web/live/member_live/index.html.heex:245 #: lib/mv_web/live/user_live/index.html.heex:74 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:221 +#: lib/mv_web/live/member_live/index.html.heex:237 #: lib/mv_web/live/user_live/form.ex:265 #: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format @@ -55,7 +55,7 @@ msgid "Edit Member" msgstr "" #: lib/mv_web/live/member_live/form.ex:47 -#: lib/mv_web/live/member_live/index.html.heex:99 +#: lib/mv_web/live/member_live/index.html.heex:107 #: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/index.html.heex:44 @@ -71,7 +71,7 @@ msgid "First Name" msgstr "" #: lib/mv_web/live/member_live/form.ex:51 -#: lib/mv_web/live/member_live/index.html.heex:207 +#: lib/mv_web/live/member_live/index.html.heex:215 #: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" @@ -88,7 +88,7 @@ msgstr "" msgid "New Member" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:218 +#: lib/mv_web/live/member_live/index.html.heex:234 #: lib/mv_web/live/user_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Show" @@ -122,7 +122,7 @@ msgid "Exit Date" msgstr "" #: lib/mv_web/live/member_live/form.ex:56 -#: lib/mv_web/live/member_live/index.html.heex:135 +#: lib/mv_web/live/member_live/index.html.heex:143 #: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "House Number" @@ -134,21 +134,24 @@ msgstr "" msgid "Notes" msgstr "" +#: lib/mv_web/live/components/payment_filter_component.ex:88 +#: lib/mv_web/live/components/payment_filter_component.ex:138 #: lib/mv_web/live/member_live/form.ex:49 +#: lib/mv_web/live/member_live/index.html.heex:224 #: lib/mv_web/live/member_live/show.ex:52 #, elixir-autogen, elixir-format msgid "Paid" msgstr "" #: lib/mv_web/live/member_live/form.ex:50 -#: lib/mv_web/live/member_live/index.html.heex:189 +#: lib/mv_web/live/member_live/index.html.heex:197 #: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "" #: lib/mv_web/live/member_live/form.ex:57 -#: lib/mv_web/live/member_live/index.html.heex:153 +#: lib/mv_web/live/member_live/index.html.heex:161 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -169,7 +172,7 @@ msgid "Saving..." msgstr "" #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:117 +#: lib/mv_web/live/member_live/index.html.heex:125 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -180,6 +183,7 @@ msgstr "" msgid "Id" msgstr "" +#: lib/mv_web/live/member_live/index.html.heex:229 #: lib/mv_web/live/member_live/index/formatter.ex:61 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format @@ -196,6 +200,7 @@ msgstr "" msgid "This is a member record from your database." msgstr "" +#: lib/mv_web/live/member_live/index.html.heex:229 #: lib/mv_web/live/member_live/index/formatter.ex:60 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format @@ -361,12 +366,12 @@ msgstr "" msgid "Required" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:55 +#: lib/mv_web/live/member_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:69 +#: lib/mv_web/live/member_live/index.html.heex:77 #, elixir-autogen, elixir-format msgid "Select member" msgstr "" @@ -552,7 +557,7 @@ msgid "Toggle dark mode" msgstr "" #: lib/mv_web/live/components/search_bar_component.ex:15 -#: lib/mv_web/live/member_live/index.html.heex:33 +#: lib/mv_web/live/member_live/index.html.heex:34 #, elixir-autogen, elixir-format msgid "Search..." msgstr "" @@ -568,7 +573,7 @@ msgstr "" msgid "Click to sort" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:81 +#: lib/mv_web/live/member_live/index.html.heex:89 #, elixir-autogen, elixir-format, fuzzy msgid "First name" msgstr "" @@ -778,7 +783,7 @@ msgstr "" msgid "Unlinking scheduled" msgstr "" -#: lib/mv_web/live/member_live/index.ex:164 +#: lib/mv_web/live/member_live/index.ex:165 #, elixir-autogen, elixir-format msgid "Copied %{count} email address to clipboard" msgid_plural "Copied %{count} email addresses to clipboard" @@ -795,12 +800,12 @@ msgstr "" msgid "Copy emails" msgstr "" -#: lib/mv_web/live/member_live/index.ex:153 +#: lib/mv_web/live/member_live/index.ex:154 #, elixir-autogen, elixir-format msgid "No email addresses found" msgstr "" -#: lib/mv_web/live/member_live/index.ex:150 +#: lib/mv_web/live/member_live/index.ex:151 #, elixir-autogen, elixir-format, fuzzy msgid "No members selected" msgstr "" @@ -815,7 +820,7 @@ msgstr "" msgid "Open in email program" msgstr "" -#: lib/mv_web/live/member_live/index.ex:173 +#: lib/mv_web/live/member_live/index.ex:174 #, elixir-autogen, elixir-format msgid "Tip: Paste email addresses into the BCC field for privacy compliance" msgstr "" @@ -832,3 +837,25 @@ msgstr "" #, elixir-autogen, elixir-format, fuzzy msgid "This field cannot be empty" msgstr "" + +#: lib/mv_web/live/components/payment_filter_component.ex:74 +#: lib/mv_web/live/components/payment_filter_component.ex:137 +#, elixir-autogen, elixir-format +msgid "All" +msgstr "" + +#: lib/mv_web/live/components/payment_filter_component.ex:48 +#, elixir-autogen, elixir-format +msgid "Filter by payment status" +msgstr "" + +#: lib/mv_web/live/components/payment_filter_component.ex:102 +#: lib/mv_web/live/components/payment_filter_component.ex:139 +#, elixir-autogen, elixir-format +msgid "Not paid" +msgstr "" + +#: lib/mv_web/live/components/payment_filter_component.ex:59 +#, elixir-autogen, elixir-format +msgid "Payment filter" +msgstr "" diff --git a/test/mv_web/components/payment_filter_component_test.exs b/test/mv_web/components/payment_filter_component_test.exs new file mode 100644 index 0000000..af2aca5 --- /dev/null +++ b/test/mv_web/components/payment_filter_component_test.exs @@ -0,0 +1,182 @@ +defmodule MvWeb.Components.PaymentFilterComponentTest do + @moduledoc """ + Unit tests for the PaymentFilterComponent. + + Tests cover: + - Rendering in all 3 filter states (nil, :paid, :not_paid) + - Event emission when selecting options + - ARIA attributes for accessibility + - Dropdown open/close behavior + """ + use MvWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + describe "rendering" do + test "renders with no filter active (nil)", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Should show "All" text and no badge + assert has_element?(view, "#payment-filter") + refute has_element?(view, "#payment-filter .badge") + end + + test "renders with paid filter active", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?paid_filter=paid") + + # Should show badge when filter is active + assert has_element?(view, "#payment-filter .badge") + end + + test "renders with not_paid filter active", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?paid_filter=not_paid") + + # Should show badge when filter is active + assert has_element?(view, "#payment-filter .badge") + end + end + + describe "dropdown behavior" do + test "dropdown opens on button click", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Initially dropdown is closed + refute has_element?(view, "#payment-filter ul[role='menu']") + + # Click to open + view + |> element("#payment-filter button[aria-haspopup='true']") + |> render_click() + + # Dropdown should be visible + assert has_element?(view, "#payment-filter ul[role='menu']") + end + + test "dropdown closes after selecting an option", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Open dropdown + view + |> element("#payment-filter button[aria-haspopup='true']") + |> render_click() + + assert has_element?(view, "#payment-filter ul[role='menu']") + + # Select an option - this should close the dropdown + view + |> element("#payment-filter button[phx-value-filter='paid']") + |> render_click() + + # After selection, dropdown should be closed + # Note: The dropdown closes via assign, which is reflected in the next render + refute has_element?(view, "#payment-filter ul[role='menu']") + end + end + + describe "filter selection" do + test "selecting 'All' clears the filter and updates URL", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?paid_filter=paid") + + # Open dropdown + view + |> element("#payment-filter button[aria-haspopup='true']") + |> render_click() + + # Select "All" option + view + |> element("#payment-filter button[phx-value-filter='']") + |> render_click() + + # URL should not contain paid_filter param - wait for patch + assert_patch(view) + end + + test "selecting 'Paid' sets the filter and updates URL", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Open dropdown + view + |> element("#payment-filter button[aria-haspopup='true']") + |> render_click() + + # Select "Paid" option + view + |> element("#payment-filter button[phx-value-filter='paid']") + |> render_click() + + # Wait for patch and check URL contains paid_filter=paid + path = assert_patch(view) + assert path =~ "paid_filter=paid" + end + + test "selecting 'Not paid' sets the filter and updates URL", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Open dropdown + view + |> element("#payment-filter button[aria-haspopup='true']") + |> render_click() + + # Select "Not paid" option + view + |> element("#payment-filter button[phx-value-filter='not_paid']") + |> render_click() + + # Wait for patch and check URL contains paid_filter=not_paid + path = assert_patch(view) + assert path =~ "paid_filter=not_paid" + end + end + + describe "accessibility" do + test "has correct ARIA attributes", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, html} = live(conn, "/members") + + # Main button should have aria-haspopup and aria-expanded + assert html =~ ~s(aria-haspopup="true") + assert html =~ ~s(aria-expanded="false") + assert html =~ ~s(aria-label=) + + # Open dropdown + view + |> element("#payment-filter button[aria-haspopup='true']") + |> render_click() + + html = render(view) + + # Check aria-expanded is now true + assert html =~ ~s(aria-expanded="true") + + # Menu should have role="menu" + assert html =~ ~s(role="menu") + + # Options should have role="menuitemradio" + assert html =~ ~s(role="menuitemradio") + end + + test "has aria-checked on selected option", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?paid_filter=paid") + + # Open dropdown + view + |> element("#payment-filter button[aria-haspopup='true']") + |> render_click() + + html = render(view) + + # "Paid" option should have aria-checked="true" + # Check both possible orderings of attributes + assert html =~ "aria-checked=\"true\"" and html =~ "phx-value-filter=\"paid\"" + end + end +end diff --git a/test/mv_web/member_live/index_custom_fields_display_test.exs b/test/mv_web/member_live/index_custom_fields_display_test.exs index 25aefe5..0485f5e 100644 --- a/test/mv_web/member_live/index_custom_fields_display_test.exs +++ b/test/mv_web/member_live/index_custom_fields_display_test.exs @@ -9,7 +9,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do - Custom field values are correctly formatted for different types - Members without custom field values show empty cell or "-" """ - use MvWeb.ConnCase, async: true + # async: false to prevent PostgreSQL deadlocks when creating members and custom fields + use MvWeb.ConnCase, async: false import Phoenix.LiveViewTest require Ash.Query diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index e3ad5bb..0bcc731 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -469,4 +469,221 @@ defmodule MvWeb.MemberLive.IndexTest do assert has_element?(view, "#flash-group") end end + + describe "payment filter integration" do + setup do + # Create members with different payment status + # Use unique names that won't appear elsewhere in the HTML + {:ok, paid_member} = + Mv.Membership.create_member(%{ + first_name: "Zahler", + last_name: "Mitglied", + email: "zahler@example.com", + paid: true + }) + + {:ok, unpaid_member} = + Mv.Membership.create_member(%{ + first_name: "Nichtzahler", + last_name: "Mitglied", + email: "nichtzahler@example.com", + paid: false + }) + + {:ok, nil_paid_member} = + Mv.Membership.create_member(%{ + first_name: "Unbestimmt", + last_name: "Mitglied", + email: "unbestimmt@example.com" + # paid is nil by default + }) + + %{paid_member: paid_member, unpaid_member: unpaid_member, nil_paid_member: nil_paid_member} + end + + test "filter shows all members when no filter is active", %{ + conn: conn, + paid_member: paid_member, + unpaid_member: unpaid_member, + nil_paid_member: nil_paid_member + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + assert html =~ paid_member.first_name + assert html =~ unpaid_member.first_name + assert html =~ nil_paid_member.first_name + end + + test "filter shows only paid members when paid filter is active", %{ + conn: conn, + paid_member: paid_member, + unpaid_member: unpaid_member, + nil_paid_member: nil_paid_member + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?paid_filter=paid") + + assert html =~ paid_member.first_name + refute html =~ unpaid_member.first_name + refute html =~ nil_paid_member.first_name + end + + test "filter shows only unpaid members (including nil) when not_paid filter is active", %{ + conn: conn, + paid_member: paid_member, + unpaid_member: unpaid_member, + nil_paid_member: nil_paid_member + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?paid_filter=not_paid") + + refute html =~ paid_member.first_name + assert html =~ unpaid_member.first_name + assert html =~ nil_paid_member.first_name + end + + test "filter combines with search query (AND)", %{ + conn: conn, + paid_member: paid_member + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?query=Zahler&paid_filter=paid") + + assert html =~ paid_member.first_name + end + + test "filter combines with sorting", %{conn: conn} do + conn = conn_with_oidc_user(conn) + + {:ok, view, _html} = + live(conn, "/members?paid_filter=paid&sort_field=first_name&sort_order=asc") + + # Click on email sort header + view + |> element("[data-testid='email']") + |> render_click() + + # Filter should be preserved in URL + path = assert_patch(view) + assert path =~ "paid_filter=paid" + assert path =~ "sort_field=email" + end + + test "URL parameter paid_filter is set when selecting filter", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Open filter dropdown + view + |> element("#payment-filter button[aria-haspopup='true']") + |> render_click() + + # Select "Paid" option + view + |> element("#payment-filter button[phx-value-filter='paid']") + |> render_click() + + path = assert_patch(view) + assert path =~ "paid_filter=paid" + end + + test "URL parameter is correctly read on page load", %{ + conn: conn, + paid_member: paid_member + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?paid_filter=paid") + + # Only paid member should be visible + assert html =~ paid_member.first_name + # Filter badge should be visible + assert html =~ "badge" + end + + test "invalid URL parameter is ignored", %{ + conn: conn, + paid_member: paid_member, + unpaid_member: unpaid_member + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?paid_filter=invalid_value") + + # All members should be visible (filter not applied) + assert html =~ paid_member.first_name + assert html =~ unpaid_member.first_name + end + + test "search maintains filter state", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?paid_filter=paid") + + # Perform search + view + |> element("[data-testid='search-input']") + |> render_change(%{"query" => "test"}) + + # Filter state should be maintained in URL + path = assert_patch(view) + assert path =~ "paid_filter=paid" + end + end + + describe "paid column in table" do + setup do + {:ok, paid_member} = + Mv.Membership.create_member(%{ + first_name: "Paid", + last_name: "Member", + email: "paid.column@example.com", + paid: true + }) + + {:ok, unpaid_member} = + Mv.Membership.create_member(%{ + first_name: "Unpaid", + last_name: "Member", + email: "unpaid.column@example.com", + paid: false + }) + + %{paid_member: paid_member, unpaid_member: unpaid_member} + end + + test "paid column shows green badge for paid members", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check for success badge (green) + assert html =~ "badge-success" + end + + test "paid column shows red badge for unpaid members", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check for error badge (red) + assert html =~ "badge-error" + end + + test "paid column shows 'Yes' for paid members", %{conn: conn} do + conn = conn_with_oidc_user(conn) + Gettext.put_locale(MvWeb.Gettext, "en") + {:ok, _view, html} = live(conn, "/members") + + # The table should contain "Yes" text inside badge + assert html =~ "badge-success" + assert html =~ "Yes" + end + + test "paid column shows 'No' for unpaid members", %{conn: conn} do + conn = conn_with_oidc_user(conn) + Gettext.put_locale(MvWeb.Gettext, "en") + {:ok, _view, html} = live(conn, "/members") + + # The table should contain "No" text inside badge + assert html =~ "badge-error" + assert html =~ "No" + end + end end