From f996aee6b2c1d7a8ac437484905dcb579bcc68c0 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 21 Jan 2026 00:47:01 +0100 Subject: [PATCH] feat: add new filter component to members view --- .../components/member_filter_component.ex | 454 ++++++++++++++++++ .../components/payment_filter_component.ex | 147 ------ lib/mv_web/live/member_live/index.ex | 38 ++ lib/mv_web/live/member_live/index.html.heex | 6 +- priv/gettext/de/LC_MESSAGES/default.po | 328 ++----------- priv/gettext/default.pot | 55 ++- priv/gettext/en/LC_MESSAGES/default.po | 69 ++- .../member_filter_component_test.exs | 267 ++++++++++ .../payment_filter_component_test.exs | 183 ------- 9 files changed, 891 insertions(+), 656 deletions(-) create mode 100644 lib/mv_web/live/components/member_filter_component.ex delete mode 100644 lib/mv_web/live/components/payment_filter_component.ex create mode 100644 test/mv_web/components/member_filter_component_test.exs delete mode 100644 test/mv_web/components/payment_filter_component_test.exs diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex new file mode 100644 index 0000000..657cb02 --- /dev/null +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -0,0 +1,454 @@ +defmodule MvWeb.Components.MemberFilterComponent do + @moduledoc """ + Provides the MemberFilter Live-Component. + + A DaisyUI dropdown filter for filtering members by payment status and boolean custom fields. + Uses radio inputs in a segmented control pattern (join + btn) for tri-state boolean filters. + + ## Design Decisions + + - Uses `div` panel instead of `ul.menu/li` structure to avoid DaisyUI menu styles + (padding, display, hover, font sizes) that would interfere with form controls. + - Filter controls are form elements (fieldset, radio inputs), not menu items. + - Dropdown stays open when clicking filter segments to allow multiple filter changes. + - Uses `phx-change` on form for radio inputs instead of individual `phx-click` events. + + ## Props + - `:cycle_status_filter` - Current payment filter state: `nil` (all), `:paid`, or `:unpaid` + - `:boolean_custom_fields` - List of boolean custom fields to display + - `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}` + - `: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 payment filter changes + - Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean 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(:cycle_status_filter, assigns[:cycle_status_filter]) + |> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || []) + |> assign(:boolean_filters, assigns[:boolean_filters] || %{}) + |> 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("update_filters", params, socket) do + # Parse payment filter + payment_filter = + case Map.get(params, "payment_filter") do + "paid" -> :paid + "unpaid" -> :unpaid + _ -> nil + end + + # Parse boolean custom field filters (including nil values for "all") + custom_boolean_filters_parsed = + params + |> Map.get("custom_boolean", %{}) + |> Enum.reduce(%{}, fn {custom_field_id_str, value_str}, acc -> + filter_value = parse_tri_state(value_str) + Map.put(acc, custom_field_id_str, filter_value) + end) + + # Update payment filter if changed + if payment_filter != socket.assigns.cycle_status_filter do + send(self(), {:payment_filter_changed, payment_filter}) + end + + # Update boolean filters - send events for each changed filter + current_filters = socket.assigns.boolean_filters + + # Process all custom field filters from form (including those set to "all"/nil) + # Radio buttons in a group always send a value, so all active filters are in the form + Enum.each(custom_boolean_filters_parsed, fn {custom_field_id_str, new_value} -> + current_value = Map.get(current_filters, custom_field_id_str) + + # Only send event if value actually changed + if current_value != new_value do + send(self(), {:boolean_filter_changed, custom_field_id_str, new_value}) + end + end) + + # Don't close dropdown - allow multiple filter changes + {:noreply, socket} + end + + @impl true + def handle_event("reset_filters", _params, socket) do + # Reset payment filter + if socket.assigns.cycle_status_filter != nil do + send(self(), {:payment_filter_changed, nil}) + end + + # Reset all boolean filters + Enum.each(socket.assigns.boolean_filters, fn {custom_field_id_str, _value} -> + send(self(), {:boolean_filter_changed, custom_field_id_str, nil}) + end) + + # Close dropdown after reset + {:noreply, assign(socket, :open, false)} + end + + # Parse tri-state filter value: "all" | "true" | "false" -> nil | true | false + defp parse_tri_state("true"), do: true + defp parse_tri_state("false"), do: false + defp parse_tri_state("all"), do: nil + defp parse_tri_state(_), do: nil + + # Get display label for button + defp button_label(cycle_status_filter, boolean_custom_fields, boolean_filters) do + # If payment filter is active, show payment filter label + if cycle_status_filter do + payment_filter_label(cycle_status_filter) + else + # Otherwise show boolean filter labels + boolean_filter_label(boolean_custom_fields, boolean_filters) + end + end + + # Get payment filter label + defp payment_filter_label(nil), do: gettext("All") + defp payment_filter_label(:paid), do: gettext("Paid") + defp payment_filter_label(:unpaid), do: gettext("Unpaid") + + # Get boolean filter label (comma-separated list of active filter names) + defp boolean_filter_label(_boolean_custom_fields, boolean_filters) + when map_size(boolean_filters) == 0 do + gettext("All") + end + + defp boolean_filter_label(boolean_custom_fields, boolean_filters) do + # Get names of active boolean filters + active_filter_names = + boolean_filters + |> Enum.map(fn {custom_field_id_str, _value} -> + Enum.find(boolean_custom_fields, fn cf -> to_string(cf.id) == custom_field_id_str end) + end) + |> Enum.filter(&(&1 != nil)) + |> Enum.map(& &1.name) + + # Join with comma and truncate if too long + label = Enum.join(active_filter_names, ", ") + truncate_label(label, 30) + end + + # Truncate label if longer than max_length + defp truncate_label(label, max_length) when byte_size(label) <= max_length, do: label + + defp truncate_label(label, max_length) do + String.slice(label, 0, max_length) <> "..." + end + + # Count active boolean filters + defp active_boolean_filters_count(boolean_filters) do + map_size(boolean_filters) + end + + # Get CSS classes for payment filter label based on current state + defp payment_filter_label_class(current_filter, expected_value) do + base_classes = "join-item btn btn-sm" + is_active = current_filter == expected_value + + cond do + # All button (nil expected) + expected_value == nil -> + if is_active do + "#{base_classes} btn-active" + else + "#{base_classes} btn" + end + + # Paid button + expected_value == :paid -> + if is_active do + "#{base_classes} btn-success btn-active" + else + "#{base_classes} btn" + end + + # Unpaid button + expected_value == :unpaid -> + if is_active do + "#{base_classes} btn-error btn-active" + else + "#{base_classes} btn" + end + + true -> + "#{base_classes} btn-outline" + end + end + + # Get CSS classes for boolean filter label based on current state + defp boolean_filter_label_class(boolean_filters, custom_field_id, expected_value) do + base_classes = "join-item btn btn-sm" + current_value = Map.get(boolean_filters, to_string(custom_field_id)) + is_active = current_value == expected_value + + cond do + # All button (nil expected) + expected_value == nil -> + if is_active do + "#{base_classes} btn-active" + else + "#{base_classes} btn" + end + + # True button + expected_value == true -> + if is_active do + "#{base_classes} btn-success btn-active" + else + "#{base_classes} btn" + end + + # False button + expected_value == false -> + if is_active do + "#{base_classes} btn-error btn-active" + else + "#{base_classes} btn" + end + + true -> + "#{base_classes} btn-outline" + end + end +end diff --git a/lib/mv_web/live/components/payment_filter_component.ex b/lib/mv_web/live/components/payment_filter_component.ex deleted file mode 100644 index 9caaa1f..0000000 --- a/lib/mv_web/live/components/payment_filter_component.ex +++ /dev/null @@ -1,147 +0,0 @@ -defmodule MvWeb.Components.PaymentFilterComponent do - @moduledoc """ - Provides the PaymentFilter Live-Component. - - A dropdown filter for filtering members by cycle payment status (paid/unpaid/all). - Uses DaisyUI dropdown styling and sends filter changes to parent LiveView. - Filter is based on cycle status (last or current cycle, depending on cycle view toggle). - - ## Props - - `:cycle_status_filter` - Current filter state: `nil` (all), `:paid`, or `:unpaid` - - `: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(:cycle_status_filter, assigns[:cycle_status_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("unpaid"), do: :unpaid - 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(:unpaid), do: gettext("Unpaid") -end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index bba6d8a..9fc9cdf 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -396,6 +396,44 @@ defmodule MvWeb.MemberLive.Index do )} end + @impl true + def handle_info({:boolean_filter_changed, custom_field_id_str, filter_value}, socket) do + # Update boolean filters map + updated_filters = + if filter_value == nil do + # Remove filter if nil (All option selected) + Map.delete(socket.assigns.boolean_custom_field_filters, custom_field_id_str) + else + # Add or update filter + Map.put(socket.assigns.boolean_custom_field_filters, custom_field_id_str, filter_value) + end + + socket = + socket + |> assign(:boolean_custom_field_filters, updated_filters) + |> load_members() + |> update_selection_assigns() + + # 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, + socket.assigns.cycle_status_filter, + socket.assigns.show_current_cycle, + updated_filters + ) + + new_path = ~p"/members?#{query_params}" + + {:noreply, + push_patch(socket, + to: new_path, + replace: true + )} + end + @impl true def handle_info({:field_toggled, field_string, visible}, socket) do # Update user field selection diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index b2af205..394db2c 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -37,9 +37,11 @@ placeholder={gettext("Search...")} /> <.live_component - module={MvWeb.Components.PaymentFilterComponent} - id="payment-filter" + module={MvWeb.Components.MemberFilterComponent} + id="member-filter" cycle_status_filter={@cycle_status_filter} + boolean_custom_fields={@boolean_custom_fields} + boolean_filters={@boolean_custom_field_filters} member_count={length(@members)} />