Merge pull request 'Implement filter for has_paid closes #227' (#237) from feature/227_payment_filter into main
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #237
This commit is contained in:
commit
ac2ad0a0d5
9 changed files with 833 additions and 81 deletions
146
lib/mv_web/live/components/payment_filter_component.ex
Normal file
146
lib/mv_web/live/components/payment_filter_component.ex
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
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"""
|
||||
<div
|
||||
class="relative"
|
||||
id={@id}
|
||||
phx-window-keydown={@open && "close_dropdown"}
|
||||
phx-key="Escape"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class={[
|
||||
"btn btn-ghost gap-2",
|
||||
@paid_filter && "btn-active"
|
||||
]}
|
||||
phx-click="toggle_dropdown"
|
||||
phx-target={@myself}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={to_string(@open)}
|
||||
aria-label={gettext("Filter by payment status")}
|
||||
>
|
||||
<.icon name="hero-funnel" class="h-5 w-5" />
|
||||
<span class="hidden sm:inline">{filter_label(@paid_filter)}</span>
|
||||
<span :if={@paid_filter} class="badge badge-primary badge-sm">{@member_count}</span>
|
||||
</button>
|
||||
|
||||
<ul
|
||||
:if={@open}
|
||||
class="menu dropdown-content bg-base-100 rounded-box z-10 w-52 p-2 shadow-lg absolute right-0 mt-2"
|
||||
role="menu"
|
||||
aria-label={gettext("Payment filter")}
|
||||
phx-click-away="close_dropdown"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={to_string(@paid_filter == nil)}
|
||||
class={@paid_filter == nil && "active"}
|
||||
phx-click="select_filter"
|
||||
phx-value-filter=""
|
||||
phx-target={@myself}
|
||||
>
|
||||
<.icon name="hero-users" class="h-4 w-4" />
|
||||
{gettext("All")}
|
||||
</button>
|
||||
</li>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={to_string(@paid_filter == :paid)}
|
||||
class={@paid_filter == :paid && "active"}
|
||||
phx-click="select_filter"
|
||||
phx-value-filter="paid"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<.icon name="hero-check-circle" class="h-4 w-4 text-success" />
|
||||
{gettext("Paid")}
|
||||
</button>
|
||||
</li>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={to_string(@paid_filter == :not_paid)}
|
||||
class={@paid_filter == :not_paid && "active"}
|
||||
phx-click="select_filter"
|
||||
phx-value-filter="not_paid"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<.icon name="hero-x-circle" class="h-4 w-4 text-error" />
|
||||
{gettext("Not paid")}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
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
|
||||
|
|
@ -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))
|
||||
|
|
@ -207,17 +208,17 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
@impl true
|
||||
def handle_info({:search_changed, q}, socket) do
|
||||
socket = load_members(socket, q)
|
||||
socket =
|
||||
socket
|
||||
|> assign(:query, q)
|
||||
|> load_members()
|
||||
|
||||
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
|
||||
}
|
||||
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 +231,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()
|
||||
|
||||
# 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,7 +272,9 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket
|
||||
|> maybe_update_search(params)
|
||||
|> maybe_update_sort(params)
|
||||
|> load_members(params["query"])
|
||||
|> maybe_update_paid_filter(params)
|
||||
|> assign(:query, params["query"])
|
||||
|> load_members()
|
||||
|> prepare_dynamic_cols()
|
||||
|
||||
{:noreply, socket}
|
||||
|
|
@ -337,11 +365,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 +382,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
|
||||
|
|
@ -370,7 +432,9 @@ defmodule MvWeb.MemberLive.Index do
|
|||
# consider implementing pagination (see Issue #165).
|
||||
#
|
||||
# Returns the socket with `:members` assigned.
|
||||
defp load_members(socket, search_query) do
|
||||
defp load_members(socket) do
|
||||
search_query = socket.assigns.query
|
||||
|
||||
query =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.new()
|
||||
|
|
@ -383,6 +447,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 +524,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 +832,29 @@ 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.
|
||||
#
|
||||
# SECURITY: This function whitelists allowed filter values. Only "paid" and "not_paid"
|
||||
# are accepted - all other input (including malicious strings) falls back to nil.
|
||||
# This ensures no raw user input is ever passed to Ash.Query.filter/2, following
|
||||
# Ash's security recommendation to never pass untrusted input directly to filters.
|
||||
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
|
||||
# -------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -26,12 +26,20 @@
|
|||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.live_component
|
||||
module={MvWeb.Components.SearchBarComponent}
|
||||
id="search-bar"
|
||||
query={@query}
|
||||
placeholder={gettext("Search...")}
|
||||
/>
|
||||
<div class="flex flex-wrap gap-4 items-center">
|
||||
<.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)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<.table
|
||||
id="members"
|
||||
|
|
@ -213,6 +221,14 @@
|
|||
>
|
||||
{member.join_date}
|
||||
</:col>
|
||||
<:col :let={member} label={gettext("Paid")}>
|
||||
<span class={[
|
||||
"badge",
|
||||
if(member.paid == true, do: "badge-success", else: "badge-error")
|
||||
]}>
|
||||
{if member.paid == true, do: gettext("Yes"), else: gettext("No")}
|
||||
</span>
|
||||
</:col>
|
||||
<:action :let={member}>
|
||||
<div class="sr-only">
|
||||
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue