Merge branch 'main' into feature/335_csv_import_ui
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
commit
9ddd1a470d
11 changed files with 2725 additions and 433 deletions
|
|
@ -19,6 +19,12 @@ defmodule Mv.Constants do
|
|||
|
||||
@custom_field_prefix "custom_field_"
|
||||
|
||||
@boolean_filter_prefix "bf_"
|
||||
|
||||
@max_boolean_filters 50
|
||||
|
||||
@max_uuid_length 36
|
||||
|
||||
@email_validator_checks [:html_input, :pow]
|
||||
|
||||
def member_fields, do: @member_fields
|
||||
|
|
@ -33,6 +39,42 @@ defmodule Mv.Constants do
|
|||
"""
|
||||
def custom_field_prefix, do: @custom_field_prefix
|
||||
|
||||
@doc """
|
||||
Returns the prefix used for boolean custom field filter URL parameters.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.Constants.boolean_filter_prefix()
|
||||
"bf_"
|
||||
"""
|
||||
def boolean_filter_prefix, do: @boolean_filter_prefix
|
||||
|
||||
@doc """
|
||||
Returns the maximum number of boolean custom field filters allowed per request.
|
||||
|
||||
This limit prevents DoS attacks by restricting the number of filter parameters
|
||||
that can be processed in a single request.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.Constants.max_boolean_filters()
|
||||
50
|
||||
"""
|
||||
def max_boolean_filters, do: @max_boolean_filters
|
||||
|
||||
@doc """
|
||||
Returns the maximum length of a UUID string (36 characters including hyphens).
|
||||
|
||||
UUIDs in standard format (e.g., "550e8400-e29b-41d4-a716-446655440000") are
|
||||
exactly 36 characters long. This constant is used for input validation.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.Constants.max_uuid_length()
|
||||
36
|
||||
"""
|
||||
def max_uuid_length, do: @max_uuid_length
|
||||
|
||||
@doc """
|
||||
Returns the email validator checks used for EctoCommons.EmailValidator.
|
||||
|
||||
|
|
|
|||
444
lib/mv_web/live/components/member_filter_component.ex
Normal file
444
lib/mv_web/live/components/member_filter_component.ex
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
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 with legend, radio inputs), not menu items.
|
||||
Uses semantic `<fieldset>` and `<legend>` for proper accessibility and form structure.
|
||||
- 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"""
|
||||
<div
|
||||
class="relative"
|
||||
id={@id}
|
||||
phx-window-keydown={@open && "close_dropdown"}
|
||||
phx-key="Escape"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
tabindex="0"
|
||||
class={[
|
||||
"btn gap-2",
|
||||
(@cycle_status_filter || active_boolean_filters_count(@boolean_filters) > 0) && "btn-active"
|
||||
]}
|
||||
phx-click="toggle_dropdown"
|
||||
phx-target={@myself}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={to_string(@open)}
|
||||
aria-label={gettext("Filter members")}
|
||||
>
|
||||
<.icon name="hero-funnel" class="h-5 w-5" />
|
||||
<span class="hidden sm:inline">
|
||||
{button_label(@cycle_status_filter, @boolean_custom_fields, @boolean_filters)}
|
||||
</span>
|
||||
<span
|
||||
:if={active_boolean_filters_count(@boolean_filters) > 0}
|
||||
class="badge badge-primary badge-sm"
|
||||
>
|
||||
{active_boolean_filters_count(@boolean_filters)}
|
||||
</span>
|
||||
<span
|
||||
:if={@cycle_status_filter && active_boolean_filters_count(@boolean_filters) == 0}
|
||||
class="badge badge-primary badge-sm"
|
||||
>
|
||||
{@member_count}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!--
|
||||
NOTE: We use a div panel instead of ul.menu/li structure to avoid DaisyUI menu styles
|
||||
(padding, display, hover, font sizes) that would interfere with our form controls.
|
||||
Filter controls are form elements (fieldset with legend, radio inputs), not menu items.
|
||||
We use semantic fieldset/legend structure for proper accessibility.
|
||||
We use relative/absolute positioning instead of DaisyUI dropdown classes to have
|
||||
full control over the open/close state via LiveView.
|
||||
-->
|
||||
<div
|
||||
:if={@open}
|
||||
tabindex="0"
|
||||
class="absolute left-0 mt-2 w-[28rem] rounded-box border border-base-300 bg-base-100 p-4 shadow-xl z-[100]"
|
||||
phx-click-away="close_dropdown"
|
||||
phx-target={@myself}
|
||||
role="dialog"
|
||||
aria-label={gettext("Member filter")}
|
||||
>
|
||||
<form phx-change="update_filters" phx-target={@myself}>
|
||||
<!-- Payment Filter Group -->
|
||||
<div class="mb-4">
|
||||
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
|
||||
{gettext("Payments")}
|
||||
</div>
|
||||
<fieldset class="grid grid-cols-[1fr_auto] items-center gap-3 py-2 border-0 p-0 m-0 min-w-0">
|
||||
<legend class="text-sm font-medium col-start-1 float-left w-auto">
|
||||
{gettext("Payment Status")}
|
||||
</legend>
|
||||
<div class="join col-start-2">
|
||||
<label
|
||||
class={"#{payment_filter_label_class(@cycle_status_filter, nil)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
|
||||
for="payment-filter-all"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
id="payment-filter-all"
|
||||
name="payment_filter"
|
||||
value="all"
|
||||
class="absolute opacity-0 w-0 h-0 pointer-events-none"
|
||||
checked={@cycle_status_filter == nil}
|
||||
/>
|
||||
<span class="text-xs">{gettext("All")}</span>
|
||||
</label>
|
||||
<label
|
||||
class={"#{payment_filter_label_class(@cycle_status_filter, :paid)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
|
||||
for="payment-filter-paid"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
id="payment-filter-paid"
|
||||
name="payment_filter"
|
||||
value="paid"
|
||||
class="absolute opacity-0 w-0 h-0 pointer-events-none"
|
||||
checked={@cycle_status_filter == :paid}
|
||||
/>
|
||||
<.icon name="hero-check-circle" class="h-5 w-5" />
|
||||
<span class="text-xs">{gettext("Paid")}</span>
|
||||
</label>
|
||||
<label
|
||||
class={"#{payment_filter_label_class(@cycle_status_filter, :unpaid)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
|
||||
for="payment-filter-unpaid"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
id="payment-filter-unpaid"
|
||||
name="payment_filter"
|
||||
value="unpaid"
|
||||
class="absolute opacity-0 w-0 h-0 pointer-events-none"
|
||||
checked={@cycle_status_filter == :unpaid}
|
||||
/>
|
||||
<.icon name="hero-x-circle" class="h-5 w-5" />
|
||||
<span class="text-xs">{gettext("Unpaid")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields Group -->
|
||||
<div :if={length(@boolean_custom_fields) > 0} class="mb-2">
|
||||
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
|
||||
{gettext("Custom Fields")}
|
||||
</div>
|
||||
<div class="max-h-60 overflow-y-auto pr-2">
|
||||
<fieldset
|
||||
:for={custom_field <- @boolean_custom_fields}
|
||||
class="grid grid-cols-[1fr_auto] items-center gap-3 py-2 border-b border-base-200 last:border-0 border-0 p-0 m-0 min-w-0"
|
||||
>
|
||||
<legend class="text-sm font-medium col-start-1 float-left w-auto">
|
||||
{custom_field.name}
|
||||
</legend>
|
||||
<div class="join col-start-2">
|
||||
<label
|
||||
class={"#{boolean_filter_label_class(@boolean_filters, custom_field.id, nil)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
|
||||
for={"custom-boolean-filter-#{custom_field.id}-all"}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
id={"custom-boolean-filter-#{custom_field.id}-all"}
|
||||
name={"custom_boolean[#{custom_field.id}]"}
|
||||
value="all"
|
||||
class="absolute opacity-0 w-0 h-0 pointer-events-none"
|
||||
checked={Map.get(@boolean_filters, to_string(custom_field.id)) == nil}
|
||||
/>
|
||||
<span class="text-xs">{gettext("All")}</span>
|
||||
</label>
|
||||
<label
|
||||
class={"#{boolean_filter_label_class(@boolean_filters, custom_field.id, true)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
|
||||
for={"custom-boolean-filter-#{custom_field.id}-true"}
|
||||
aria-label={gettext("Yes")}
|
||||
title={gettext("Yes")}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
id={"custom-boolean-filter-#{custom_field.id}-true"}
|
||||
name={"custom_boolean[#{custom_field.id}]"}
|
||||
value="true"
|
||||
class="absolute opacity-0 w-0 h-0 pointer-events-none"
|
||||
checked={Map.get(@boolean_filters, to_string(custom_field.id)) == true}
|
||||
/>
|
||||
<.icon name="hero-check-circle" class="h-5 w-5" />
|
||||
<span class="text-xs">{gettext("Yes")}</span>
|
||||
</label>
|
||||
<label
|
||||
class={"#{boolean_filter_label_class(@boolean_filters, custom_field.id, false)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
|
||||
for={"custom-boolean-filter-#{custom_field.id}-false"}
|
||||
aria-label={gettext("No")}
|
||||
title={gettext("No")}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
id={"custom-boolean-filter-#{custom_field.id}-false"}
|
||||
name={"custom_boolean[#{custom_field.id}]"}
|
||||
value="false"
|
||||
class="absolute opacity-0 w-0 h-0 pointer-events-none"
|
||||
checked={Map.get(@boolean_filters, to_string(custom_field.id)) == false}
|
||||
/>
|
||||
<.icon name="hero-x-circle" class="h-5 w-5" />
|
||||
<span class="text-xs">{gettext("No")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-4 flex justify-between pt-3 border-t border-base-200">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="reset_filters"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm"
|
||||
>
|
||||
{gettext("Reset")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="close_dropdown"
|
||||
phx-target={@myself}
|
||||
class="btn btn-primary btn-sm"
|
||||
>
|
||||
{gettext("Close")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</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("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
|
||||
# Send single message to reset all filters at once (performance optimization)
|
||||
# This avoids N×2 load_members() calls when resetting multiple filters
|
||||
send(self(), {:reset_all_filters, nil, %{}})
|
||||
|
||||
# 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
|
||||
|
|
@ -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"""
|
||||
<div
|
||||
class="relative"
|
||||
id={@id}
|
||||
phx-window-keydown={@open && "close_dropdown"}
|
||||
phx-key="Escape"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class={[
|
||||
"btn gap-2",
|
||||
@cycle_status_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(@cycle_status_filter)}</span>
|
||||
<span :if={@cycle_status_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(@cycle_status_filter == nil)}
|
||||
class={@cycle_status_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(@cycle_status_filter == :paid)}
|
||||
class={@cycle_status_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(@cycle_status_filter == :unpaid)}
|
||||
class={@cycle_status_filter == :unpaid && "active"}
|
||||
phx-click="select_filter"
|
||||
phx-value-filter="unpaid"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<.icon name="hero-x-circle" class="h-4 w-4 text-error" />
|
||||
{gettext("Unpaid")}
|
||||
</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("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
|
||||
|
|
@ -28,6 +28,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
use MvWeb, :live_view
|
||||
|
||||
require Ash.Query
|
||||
require Logger
|
||||
import Ash.Expr
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
|
||||
|
|
@ -41,6 +42,15 @@ defmodule MvWeb.MemberLive.Index do
|
|||
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
|
||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||
|
||||
# Prefix used for boolean custom field filter URL parameters (e.g., "bf_<id>")
|
||||
@boolean_filter_prefix Mv.Constants.boolean_filter_prefix()
|
||||
|
||||
# Maximum number of boolean custom field filters allowed per request (DoS protection)
|
||||
@max_boolean_filters Mv.Constants.max_boolean_filters()
|
||||
|
||||
# Maximum length of UUID string (36 characters including hyphens)
|
||||
@max_uuid_length Mv.Constants.max_uuid_length()
|
||||
|
||||
# Member fields that are loaded for the overview
|
||||
# Uses constants from Mv.Constants to ensure consistency
|
||||
# Note: :id is always included for member identification
|
||||
|
|
@ -72,6 +82,12 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
# Load boolean custom fields (filtered and sorted from all_custom_fields)
|
||||
boolean_custom_fields =
|
||||
all_custom_fields
|
||||
|> Enum.filter(&(&1.value_type == :boolean))
|
||||
|> Enum.sort_by(& &1.name, :asc)
|
||||
|
||||
# Load settings once to avoid N+1 queries
|
||||
settings =
|
||||
case Membership.get_settings() do
|
||||
|
|
@ -101,10 +117,12 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> assign_new(:sort_field, fn -> :first_name end)
|
||||
|> assign_new(:sort_order, fn -> :asc end)
|
||||
|> assign(:cycle_status_filter, nil)
|
||||
|> assign(:boolean_custom_field_filters, %{})
|
||||
|> assign(:selected_members, MapSet.new())
|
||||
|> assign(:settings, settings)
|
||||
|> assign(:custom_fields_visible, custom_fields_visible)
|
||||
|> assign(:all_custom_fields, all_custom_fields)
|
||||
|> assign(:boolean_custom_fields, boolean_custom_fields)
|
||||
|> assign(:all_available_fields, all_available_fields)
|
||||
|> assign(:user_field_selection, initial_selection)
|
||||
|> assign(
|
||||
|
|
@ -218,7 +236,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.sort_field,
|
||||
socket.assigns.sort_order,
|
||||
socket.assigns.cycle_status_filter,
|
||||
new_show_current
|
||||
new_show_current,
|
||||
socket.assigns.boolean_custom_field_filters
|
||||
)
|
||||
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
|
|
@ -332,7 +351,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
existing_field_query,
|
||||
existing_sort_query,
|
||||
socket.assigns.cycle_status_filter,
|
||||
socket.assigns.show_current_cycle
|
||||
socket.assigns.show_current_cycle,
|
||||
socket.assigns.boolean_custom_field_filters
|
||||
)
|
||||
|
||||
# Set the new path with params
|
||||
|
|
@ -361,7 +381,77 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.sort_field,
|
||||
socket.assigns.sort_order,
|
||||
filter,
|
||||
socket.assigns.show_current_cycle
|
||||
socket.assigns.show_current_cycle,
|
||||
socket.assigns.boolean_custom_field_filters
|
||||
)
|
||||
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
|
||||
{:noreply,
|
||||
push_patch(socket,
|
||||
to: new_path,
|
||||
replace: true
|
||||
)}
|
||||
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({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
|
||||
# Reset all filters at once (performance optimization)
|
||||
# This avoids N×2 load_members() calls when resetting multiple filters
|
||||
socket =
|
||||
socket
|
||||
|> assign(:cycle_status_filter, cycle_status_filter)
|
||||
|> assign(:boolean_custom_field_filters, boolean_filters)
|
||||
|> load_members()
|
||||
|> update_selection_assigns()
|
||||
|
||||
# Build the URL with all params including reset filters
|
||||
query_params =
|
||||
build_query_params(
|
||||
socket.assigns.query,
|
||||
socket.assigns.sort_field,
|
||||
socket.assigns.sort_order,
|
||||
cycle_status_filter,
|
||||
socket.assigns.show_current_cycle,
|
||||
boolean_filters
|
||||
)
|
||||
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
|
|
@ -448,6 +538,9 @@ defmodule MvWeb.MemberLive.Index do
|
|||
"""
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
# Build signature BEFORE updates to detect if anything actually changed
|
||||
prev_sig = build_signature(socket)
|
||||
|
||||
# Parse field selection from URL
|
||||
url_selection = FieldSelection.parse_from_url(params)
|
||||
|
||||
|
|
@ -471,23 +564,68 @@ defmodule MvWeb.MemberLive.Index do
|
|||
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
|
||||
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
|
||||
|
||||
# Apply all updates
|
||||
socket =
|
||||
socket
|
||||
|> maybe_update_search(params)
|
||||
|> maybe_update_sort(params)
|
||||
|> maybe_update_cycle_status_filter(params)
|
||||
|> maybe_update_boolean_filters(params)
|
||||
|> maybe_update_show_current_cycle(params)
|
||||
|> assign(:query, params["query"])
|
||||
|> assign(:user_field_selection, final_selection)
|
||||
|> assign(:member_fields_visible, visible_member_fields)
|
||||
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
||||
|
||||
# Build signature AFTER updates
|
||||
next_sig = build_signature(socket)
|
||||
|
||||
# Only load members if signature changed (optimization: avoid duplicate loads)
|
||||
# OR if members haven't been loaded yet (first handle_params call after mount)
|
||||
socket =
|
||||
if prev_sig == next_sig && Map.has_key?(socket.assigns, :members) do
|
||||
# Nothing changed AND members already loaded, skip expensive load_members() call
|
||||
socket
|
||||
|> prepare_dynamic_cols()
|
||||
|> update_selection_assigns()
|
||||
else
|
||||
# Signature changed OR members not loaded yet, reload members
|
||||
socket
|
||||
|> load_members()
|
||||
|> prepare_dynamic_cols()
|
||||
|> update_selection_assigns()
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Builds a signature tuple representing all filter/sort parameters that affect member loading.
|
||||
#
|
||||
# This signature is used to detect if member data needs to be reloaded when handle_params
|
||||
# is called. If the signature hasn't changed, we can skip the expensive load_members() call.
|
||||
#
|
||||
# Returns a tuple containing all relevant parameters:
|
||||
# - query: Search query string
|
||||
# - sort_field: Field to sort by
|
||||
# - sort_order: Sort direction (:asc or :desc)
|
||||
# - cycle_status_filter: Payment filter (:paid, :unpaid, or nil)
|
||||
# - show_current_cycle: Whether to show current cycle
|
||||
# - boolean_custom_field_filters: Map of active boolean filters
|
||||
# - user_field_selection: Map of user's field visibility selections
|
||||
# - visible_custom_field_ids: List of visible custom field IDs (affects which custom fields are loaded)
|
||||
defp build_signature(socket) do
|
||||
{
|
||||
socket.assigns.query,
|
||||
socket.assigns.sort_field,
|
||||
socket.assigns.sort_order,
|
||||
socket.assigns.cycle_status_filter,
|
||||
socket.assigns.show_current_cycle,
|
||||
socket.assigns.boolean_custom_field_filters,
|
||||
socket.assigns.user_field_selection,
|
||||
socket.assigns[:visible_custom_field_ids] || []
|
||||
}
|
||||
end
|
||||
|
||||
# Prepares dynamic column definitions for custom fields that should be shown in the overview.
|
||||
#
|
||||
# Creates a list of column definitions, each containing:
|
||||
|
|
@ -586,7 +724,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
field_str,
|
||||
Atom.to_string(order),
|
||||
socket.assigns.cycle_status_filter,
|
||||
socket.assigns.show_current_cycle
|
||||
socket.assigns.show_current_cycle,
|
||||
socket.assigns.boolean_custom_field_filters
|
||||
)
|
||||
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
|
|
@ -616,7 +755,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.sort_field,
|
||||
socket.assigns.sort_order,
|
||||
socket.assigns.cycle_status_filter,
|
||||
socket.assigns.show_current_cycle
|
||||
socket.assigns.show_current_cycle,
|
||||
socket.assigns.boolean_custom_field_filters
|
||||
)
|
||||
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
|
||||
|
||||
|
|
@ -634,12 +774,14 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
# Builds URL query parameters map including all filter/sort state.
|
||||
# Converts cycle_status_filter atom to string for URL.
|
||||
# Adds boolean custom field filters as bf_<id>=true|false.
|
||||
defp build_query_params(
|
||||
query,
|
||||
sort_field,
|
||||
sort_order,
|
||||
cycle_status_filter,
|
||||
show_current_cycle
|
||||
show_current_cycle,
|
||||
boolean_filters
|
||||
) do
|
||||
field_str =
|
||||
if is_atom(sort_field) do
|
||||
|
|
@ -670,11 +812,19 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
|
||||
# Add show_current_cycle if true
|
||||
base_params =
|
||||
if show_current_cycle do
|
||||
Map.put(base_params, "show_current_cycle", "true")
|
||||
else
|
||||
base_params
|
||||
end
|
||||
|
||||
# Add boolean custom field filters
|
||||
Enum.reduce(boolean_filters, base_params, fn {custom_field_id, filter_value}, acc ->
|
||||
param_key = "#{@boolean_filter_prefix}#{custom_field_id}"
|
||||
param_value = if filter_value == true, do: "true", else: "false"
|
||||
Map.put(acc, param_key, param_value)
|
||||
end)
|
||||
end
|
||||
|
||||
# Loads members from the database with custom field values and applies search/sort/payment filters.
|
||||
|
|
@ -704,9 +854,32 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> Ash.Query.new()
|
||||
|> Ash.Query.select(@overview_fields)
|
||||
|
||||
# Load custom field values for visible custom fields (based on user selection)
|
||||
# Load custom field values for visible custom fields AND active boolean filters
|
||||
# This ensures boolean filters work even when the custom field is not visible in overview
|
||||
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
||||
query = load_custom_field_values(query, visible_custom_field_ids)
|
||||
|
||||
# Get IDs of active boolean filters (whitelisted against boolean_custom_fields)
|
||||
# Convert boolean_custom_fields list to map for efficient lookup (consistent with maybe_update_boolean_filters)
|
||||
boolean_custom_fields_map =
|
||||
socket.assigns.boolean_custom_fields
|
||||
|> Map.new(fn cf -> {to_string(cf.id), cf} end)
|
||||
|
||||
active_boolean_filter_ids =
|
||||
socket.assigns.boolean_custom_field_filters
|
||||
|> Map.keys()
|
||||
|> Enum.filter(fn id_str ->
|
||||
# Validate UUID format and check against whitelist
|
||||
String.length(id_str) <= @max_uuid_length &&
|
||||
match?({:ok, _}, Ecto.UUID.cast(id_str)) &&
|
||||
Map.has_key?(boolean_custom_fields_map, id_str)
|
||||
end)
|
||||
|
||||
# Union of visible IDs and active filter IDs
|
||||
ids_to_load =
|
||||
(visible_custom_field_ids ++ active_boolean_filter_ids)
|
||||
|> Enum.uniq()
|
||||
|
||||
query = load_custom_field_values(query, ids_to_load)
|
||||
|
||||
# Load membership fee cycles for status display
|
||||
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
|
||||
|
|
@ -726,7 +899,9 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
# Errors in handle_params are handled by Phoenix LiveView
|
||||
actor = current_actor(socket)
|
||||
members = Ash.read!(query, actor: actor)
|
||||
{time_microseconds, members} = :timer.tc(fn -> Ash.read!(query, actor: actor) end)
|
||||
time_milliseconds = time_microseconds / 1000
|
||||
Logger.info("Ash.read! in load_members/1 took #{time_milliseconds} ms")
|
||||
|
||||
# Custom field values are already filtered at the database level in load_custom_field_values/2
|
||||
# No need for in-memory filtering anymore
|
||||
|
|
@ -739,6 +914,14 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.show_current_cycle
|
||||
)
|
||||
|
||||
# Apply boolean custom field filters if set
|
||||
members =
|
||||
apply_boolean_custom_field_filters(
|
||||
members,
|
||||
socket.assigns.boolean_custom_field_filters,
|
||||
socket.assigns.all_custom_fields
|
||||
)
|
||||
|
||||
# Sort in memory if needed (for custom fields)
|
||||
members =
|
||||
if sort_after_load do
|
||||
|
|
@ -1133,6 +1316,142 @@ defmodule MvWeb.MemberLive.Index do
|
|||
defp determine_cycle_status_filter("unpaid"), do: :unpaid
|
||||
defp determine_cycle_status_filter(_), do: nil
|
||||
|
||||
# Updates boolean custom field filters from URL parameters if present.
|
||||
#
|
||||
# Parses all URL parameters with prefix @boolean_filter_prefix and validates them:
|
||||
# - Extracts custom field ID from parameter name (explicitly removes prefix)
|
||||
# - Validates filter value using determine_boolean_filter/1
|
||||
# - Whitelisting: Only custom field IDs that exist and have value_type: :boolean
|
||||
# - Security: Limits to maximum @max_boolean_filters filters to prevent DoS attacks
|
||||
# - Security: Validates UUID length (max @max_uuid_length characters)
|
||||
#
|
||||
# Returns socket with updated :boolean_custom_field_filters assign.
|
||||
defp maybe_update_boolean_filters(socket, params) do
|
||||
# Get all boolean custom fields for whitelisting (keyed by ID as string for consistency)
|
||||
boolean_custom_fields =
|
||||
socket.assigns.all_custom_fields
|
||||
|> Enum.filter(&(&1.value_type == :boolean))
|
||||
|> Map.new(fn cf -> {to_string(cf.id), cf} end)
|
||||
|
||||
# Parse all boolean filter parameters
|
||||
# Security: Use reduce_while to abort early after @max_boolean_filters to prevent DoS attacks
|
||||
# This protects CPU/Parsing costs, not just memory/state
|
||||
# We count processed parameters (not just valid filters) to protect against parsing DoS
|
||||
prefix_length = String.length(@boolean_filter_prefix)
|
||||
|
||||
{filters, total_processed} =
|
||||
params
|
||||
|> Enum.filter(fn {key, _value} -> String.starts_with?(key, @boolean_filter_prefix) end)
|
||||
|> Enum.reduce_while({%{}, 0}, fn {key, value_str}, {acc, count} ->
|
||||
if count >= @max_boolean_filters do
|
||||
{:halt, {acc, count}}
|
||||
else
|
||||
new_acc =
|
||||
process_boolean_filter_param(
|
||||
key,
|
||||
value_str,
|
||||
prefix_length,
|
||||
boolean_custom_fields,
|
||||
acc
|
||||
)
|
||||
|
||||
# Increment counter for each processed parameter (DoS protection)
|
||||
# Note: We count processed params, not just valid filters, to protect parsing costs
|
||||
{:cont, {new_acc, count + 1}}
|
||||
end
|
||||
end)
|
||||
|
||||
# Log warning if we hit the limit
|
||||
if total_processed >= @max_boolean_filters do
|
||||
Logger.warning(
|
||||
"Boolean filter limit reached: processed #{total_processed} parameters, accepted #{map_size(filters)} valid filters (max: #{@max_boolean_filters})"
|
||||
)
|
||||
end
|
||||
|
||||
assign(socket, :boolean_custom_field_filters, filters)
|
||||
end
|
||||
|
||||
# Processes a single boolean filter parameter from URL params.
|
||||
#
|
||||
# Validates the parameter and adds it to the accumulator if valid.
|
||||
# Returns the accumulator unchanged if validation fails.
|
||||
defp process_boolean_filter_param(
|
||||
key,
|
||||
value_str,
|
||||
prefix_length,
|
||||
boolean_custom_fields,
|
||||
acc
|
||||
) do
|
||||
# Extract custom field ID from parameter name (explicitly remove prefix)
|
||||
# This is more secure than String.replace_prefix which only removes first occurrence
|
||||
custom_field_id_str = String.slice(key, prefix_length, String.length(key) - prefix_length)
|
||||
|
||||
# Validate custom field ID length (UUIDs are max @max_uuid_length characters)
|
||||
# This provides an additional security layer beyond UUID format validation
|
||||
if String.length(custom_field_id_str) > @max_uuid_length do
|
||||
acc
|
||||
else
|
||||
validate_and_add_boolean_filter(
|
||||
custom_field_id_str,
|
||||
value_str,
|
||||
boolean_custom_fields,
|
||||
acc
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Validates UUID format and custom field existence, then adds filter if valid.
|
||||
defp validate_and_add_boolean_filter(
|
||||
custom_field_id_str,
|
||||
value_str,
|
||||
boolean_custom_fields,
|
||||
acc
|
||||
) do
|
||||
case Ecto.UUID.cast(custom_field_id_str) do
|
||||
{:ok, _custom_field_id} ->
|
||||
add_boolean_filter_if_valid(
|
||||
custom_field_id_str,
|
||||
value_str,
|
||||
boolean_custom_fields,
|
||||
acc
|
||||
)
|
||||
|
||||
:error ->
|
||||
acc
|
||||
end
|
||||
end
|
||||
|
||||
# Adds boolean filter to accumulator if custom field exists and value is valid.
|
||||
defp add_boolean_filter_if_valid(
|
||||
custom_field_id_str,
|
||||
value_str,
|
||||
boolean_custom_fields,
|
||||
acc
|
||||
) do
|
||||
if Map.has_key?(boolean_custom_fields, custom_field_id_str) do
|
||||
case determine_boolean_filter(value_str) do
|
||||
nil -> acc
|
||||
filter_value -> Map.put(acc, custom_field_id_str, filter_value)
|
||||
end
|
||||
else
|
||||
acc
|
||||
end
|
||||
end
|
||||
|
||||
# Determines valid boolean filter value from URL parameter.
|
||||
#
|
||||
# SECURITY: This function whitelists allowed filter values. Only "true" and "false"
|
||||
# are accepted - all other input (including malicious strings) falls back to nil.
|
||||
# This ensures no raw user input is ever passed to filter functions.
|
||||
#
|
||||
# Returns:
|
||||
# - `true` for "true" string
|
||||
# - `false` for "false" string
|
||||
# - `nil` for any other value
|
||||
defp determine_boolean_filter("true"), do: true
|
||||
defp determine_boolean_filter("false"), do: false
|
||||
defp determine_boolean_filter(_), do: nil
|
||||
|
||||
# Updates show_current_cycle from URL parameters if present.
|
||||
defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}) do
|
||||
assign(socket, :show_current_cycle, true)
|
||||
|
|
@ -1166,7 +1485,166 @@ defmodule MvWeb.MemberLive.Index do
|
|||
values when is_list(values) ->
|
||||
Enum.find(values, fn cfv ->
|
||||
cfv.custom_field_id == custom_field.id or
|
||||
(cfv.custom_field && cfv.custom_field.id == custom_field.id)
|
||||
(match?(%{custom_field: %{id: _}}, cfv) && cfv.custom_field.id == custom_field.id)
|
||||
end)
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Extracts the boolean value from a member's custom field value.
|
||||
#
|
||||
# Handles different value formats:
|
||||
# - `%Ash.Union{value: value, type: :boolean}` - Extracts value from union
|
||||
# - Map format with `"type"` and `"value"` keys - Extracts from map
|
||||
# - Map format with `"_union_type"` and `"_union_value"` keys - Extracts from map
|
||||
#
|
||||
# Returns:
|
||||
# - `true` if the custom field value is boolean true
|
||||
# - `false` if the custom field value is boolean false
|
||||
# - `nil` if no custom field value exists, value is nil, or value is not boolean
|
||||
#
|
||||
# Examples:
|
||||
# get_boolean_custom_field_value(member, boolean_field) -> true
|
||||
# get_boolean_custom_field_value(member, non_existent_field) -> nil
|
||||
def get_boolean_custom_field_value(member, custom_field) do
|
||||
case get_custom_field_value(member, custom_field) do
|
||||
nil ->
|
||||
nil
|
||||
|
||||
cfv ->
|
||||
extract_boolean_value(cfv.value)
|
||||
end
|
||||
end
|
||||
|
||||
# Extracts boolean value from custom field value, handling different formats.
|
||||
#
|
||||
# Handles:
|
||||
# - `%Ash.Union{value: value, type: :boolean}` - Union struct format
|
||||
# - Map with `"type"` and `"value"` keys - JSONB map format
|
||||
# - Map with `"_union_type"` and `"_union_value"` keys - Alternative map format
|
||||
# - Direct boolean value - Primitive boolean
|
||||
#
|
||||
# Returns `true`, `false`, or `nil`.
|
||||
defp extract_boolean_value(%Ash.Union{value: value, type: :boolean}) do
|
||||
extract_boolean_value(value)
|
||||
end
|
||||
|
||||
defp extract_boolean_value(value) when is_map(value) do
|
||||
# Handle map format from JSONB
|
||||
type = Map.get(value, "type") || Map.get(value, "_union_type")
|
||||
val = Map.get(value, "value") || Map.get(value, "_union_value")
|
||||
|
||||
if type == "boolean" or type == :boolean do
|
||||
extract_boolean_value(val)
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_boolean_value(value) when is_boolean(value), do: value
|
||||
defp extract_boolean_value(nil), do: nil
|
||||
defp extract_boolean_value(_), do: nil
|
||||
|
||||
# Applies boolean custom field filters to a list of members.
|
||||
#
|
||||
# Filters members based on boolean custom field values. Only members that match
|
||||
# ALL active filters (AND logic) are returned.
|
||||
#
|
||||
# Parameters:
|
||||
# - `members` - List of Member resources with loaded custom_field_values
|
||||
# - `filters` - Map of `%{custom_field_id_string => true | false}`
|
||||
# - `all_custom_fields` - List of all CustomField resources (for validation)
|
||||
#
|
||||
# Returns:
|
||||
# - Filtered list of members that match all active filters
|
||||
# - All members if filters map is empty
|
||||
# - Filters with non-existent custom field IDs are ignored
|
||||
#
|
||||
# Examples:
|
||||
# apply_boolean_custom_field_filters(members, %{"uuid-123" => true}, all_custom_fields) -> [member1, ...]
|
||||
# apply_boolean_custom_field_filters(members, %{}, all_custom_fields) -> members
|
||||
def apply_boolean_custom_field_filters(members, filters, _all_custom_fields)
|
||||
when map_size(filters) == 0 do
|
||||
members
|
||||
end
|
||||
|
||||
def apply_boolean_custom_field_filters(members, filters, all_custom_fields) do
|
||||
# Build a map of valid boolean custom field IDs (as strings) for quick lookup
|
||||
valid_custom_field_ids =
|
||||
all_custom_fields
|
||||
|> Enum.filter(&(&1.value_type == :boolean))
|
||||
|> MapSet.new(fn cf -> to_string(cf.id) end)
|
||||
|
||||
# Filter out invalid custom field IDs from filters
|
||||
valid_filters =
|
||||
Enum.filter(filters, fn {custom_field_id_str, _value} ->
|
||||
MapSet.member?(valid_custom_field_ids, custom_field_id_str)
|
||||
end)
|
||||
|> Enum.into(%{})
|
||||
|
||||
# If no valid filters remain, return all members
|
||||
if map_size(valid_filters) == 0 do
|
||||
members
|
||||
else
|
||||
Enum.filter(members, fn member ->
|
||||
matches_all_filters?(member, valid_filters)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
# Checks if a member matches all active boolean filters.
|
||||
#
|
||||
# A member matches a filter if:
|
||||
# - The filter value is `true` and the member's custom field value is `true`
|
||||
# - The filter value is `false` and the member's custom field value is `false`
|
||||
#
|
||||
# Members without a custom field value or with `nil` value do not match any filter.
|
||||
#
|
||||
# Returns `true` if all filters match, `false` otherwise.
|
||||
defp matches_all_filters?(member, filters) do
|
||||
Enum.all?(filters, fn {custom_field_id_str, filter_value} ->
|
||||
matches_filter?(member, custom_field_id_str, filter_value)
|
||||
end)
|
||||
end
|
||||
|
||||
# Checks if a member matches a specific boolean filter.
|
||||
#
|
||||
# Finds the custom field value by ID and checks if the member's boolean value
|
||||
# matches the filter value.
|
||||
#
|
||||
# Returns:
|
||||
# - `true` if the member's boolean value matches the filter value
|
||||
# - `false` if no custom field value exists (member is filtered out)
|
||||
# - `false` if value is nil or values don't match
|
||||
defp matches_filter?(member, custom_field_id_str, filter_value) do
|
||||
case find_custom_field_value_by_id(member, custom_field_id_str) do
|
||||
nil ->
|
||||
false
|
||||
|
||||
cfv ->
|
||||
boolean_value = extract_boolean_value(cfv.value)
|
||||
boolean_value == filter_value
|
||||
end
|
||||
end
|
||||
|
||||
# Finds a custom field value by custom field ID string.
|
||||
#
|
||||
# Searches through the member's custom_field_values to find one matching
|
||||
# the given custom field ID.
|
||||
#
|
||||
# Returns the CustomFieldValue or nil.
|
||||
defp find_custom_field_value_by_id(member, custom_field_id_str) do
|
||||
case member.custom_field_values do
|
||||
nil ->
|
||||
nil
|
||||
|
||||
values when is_list(values) ->
|
||||
Enum.find(values, fn cfv ->
|
||||
to_string(cfv.custom_field_id) == custom_field_id_str or
|
||||
(match?(%{custom_field: %{id: _}}, cfv) &&
|
||||
to_string(cfv.custom_field.id) == custom_field_id_str)
|
||||
end)
|
||||
|
||||
_ ->
|
||||
|
|
@ -1221,8 +1699,11 @@ defmodule MvWeb.MemberLive.Index do
|
|||
#
|
||||
# Note: Mailto URLs have length limits that vary by email client.
|
||||
# For large selections, consider using export functionality instead.
|
||||
#
|
||||
# Handles case where members haven't been loaded yet (e.g., when signature didn't change in handle_params).
|
||||
defp update_selection_assigns(socket) do
|
||||
members = socket.assigns.members
|
||||
# Handle case where members haven't been loaded yet (e.g., when signature didn't change)
|
||||
members = socket.assigns[:members] || []
|
||||
selected_members = socket.assigns.selected_members
|
||||
|
||||
selected_count =
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ msgstr "Hausnummer"
|
|||
msgid "Notes"
|
||||
msgstr "Notizen"
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||
|
|
@ -182,6 +182,7 @@ msgstr "Speichern..."
|
|||
msgid "Street"
|
||||
msgstr "Straße"
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||
|
|
@ -196,6 +197,7 @@ msgstr "Nein"
|
|||
msgid "Show Member"
|
||||
msgstr "Mitglied anzeigen"
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||
|
|
@ -578,6 +580,7 @@ msgstr "E-Mail kann nicht aktualisiert werden: Diese E-Mail-Adresse ist bereits
|
|||
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
|
||||
msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft. Es können nicht mehrere OIDC-Provider mit demselben Konto verknüpft werden."
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -739,21 +742,11 @@ msgid "This field cannot be empty"
|
|||
msgstr "Dieses Feld darf nicht leer bleiben"
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "All"
|
||||
msgstr "Alle"
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Filter by payment status"
|
||||
msgstr "Nach Zahlungsstatus filtern"
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Payment filter"
|
||||
msgstr "Zahlungsfilter"
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Address"
|
||||
|
|
@ -786,6 +779,7 @@ msgstr "Nr."
|
|||
msgid "Payment Data"
|
||||
msgstr "Beitragsdaten"
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Payments"
|
||||
|
|
@ -920,7 +914,7 @@ msgstr "Status"
|
|||
msgid "Suspended"
|
||||
msgstr "Pausiert"
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||
|
|
@ -1926,6 +1920,31 @@ msgstr "Validierung fehlgeschlagen: %{field} %{message}"
|
|||
msgid "Validation failed: %{message}"
|
||||
msgstr "Validierung fehlgeschlagen: %{message}"
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Close"
|
||||
msgstr "Schließen"
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Filter members"
|
||||
msgstr "Mitglieder filtern"
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member filter"
|
||||
msgstr "Mitgliedsfilter"
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Payment Status"
|
||||
msgstr "Bezahlstatus"
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Reset"
|
||||
msgstr "Zurücksetzen"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Only administrators can regenerate cycles"
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ msgstr ""
|
|||
msgid "Notes"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||
|
|
@ -183,6 +183,7 @@ msgstr ""
|
|||
msgid "Street"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||
|
|
@ -197,6 +198,7 @@ msgstr ""
|
|||
msgid "Show Member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||
|
|
@ -579,6 +581,7 @@ msgstr ""
|
|||
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -740,21 +743,11 @@ msgid "This field cannot be empty"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "All"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Filter by payment status"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Payment filter"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Address"
|
||||
|
|
@ -787,6 +780,7 @@ msgstr ""
|
|||
msgid "Payment Data"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Payments"
|
||||
|
|
@ -921,7 +915,7 @@ msgstr ""
|
|||
msgid "Suspended"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||
|
|
@ -1927,6 +1921,31 @@ msgstr ""
|
|||
msgid "Validation failed: %{message}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Filter members"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member filter"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Payment Status"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Reset"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Only administrators can regenerate cycles"
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ msgstr ""
|
|||
msgid "Notes"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||
|
|
@ -183,6 +183,7 @@ msgstr ""
|
|||
msgid "Street"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||
|
|
@ -197,6 +198,7 @@ msgstr ""
|
|||
msgid "Show Member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||
|
|
@ -579,6 +581,7 @@ msgstr ""
|
|||
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
|
|
@ -740,21 +743,11 @@ msgid "This field cannot be empty"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "All"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Filter by payment status"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Payment filter"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Address"
|
||||
|
|
@ -787,6 +780,7 @@ msgstr ""
|
|||
msgid "Payment Data"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Payments"
|
||||
|
|
@ -921,7 +915,7 @@ msgstr ""
|
|||
msgid "Suspended"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||
|
|
@ -1927,7 +1921,339 @@ msgstr ""
|
|||
msgid "Validation failed: %{message}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Filter members"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Member filter"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Payment Status"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Reset"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Only administrators can regenerate cycles"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Use this form to manage Custom Field Value records in your database."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Choose a custom field"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Joining year - reduced to 0"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/components/layouts/sidebar.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Admin"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Regular"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/components/member_filter_component.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Payment"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Current"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Paid via bank transfer"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Mark as Unpaid"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Half-yearly contribution for supporting members"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Reduced fee for unemployed, pensioners, or low income"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_value_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Custom field value not found"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Supporting Member"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Monthly fee for students and trainees"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Filter by payment status"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Custom field value %{action} successfully"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Total Contributions"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Manage contribution types for membership fees."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Change Contribution Type"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "New Contribution Type"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Time Period"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_value_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Custom field value deleted successfully"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_value_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "You do not have permission to access this custom field value"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Cannot delete - members assigned"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Preview Mockup"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Contribution Types"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "This page is not functional and only displays the planned features."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Member since"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Unsupported value type: %{type}"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Custom field"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Mark as Paid"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Contribution type"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/components/layouts/sidebar.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Contributions"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Reduced"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "No fee for honorary members"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_value_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "You do not have permission to delete this custom field value"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "%{count} period selected"
|
||||
#~ msgid_plural "%{count} periods selected"
|
||||
#~ msgstr[0] ""
|
||||
#~ msgstr[1] ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Mark as Suspended"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Contribution types define different membership fee structures. Each type has a fixed cycle (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Choose a member"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Suspend"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Reopen"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Value"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Why are not all contribution types shown?"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Contribution Start"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Standard membership fee for regular members"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Save Custom Field Value"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Honorary"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Contributions for %{name}"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/components/member_filter_component.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Payment status filter"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Family"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_value_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "You do not have permission to view custom field values"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Student"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Quarterly fee for family memberships"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_value_live/form.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Please select a custom field first"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Open Contributions"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Member Contributions"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "About Contribution Types"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/components/member_filter_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Filter by %{name}"
|
||||
#~ msgstr ""
|
||||
|
|
|
|||
300
test/mv_web/components/member_filter_component_test.exs
Normal file
300
test/mv_web/components/member_filter_component_test.exs
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
defmodule MvWeb.Components.MemberFilterComponentTest do
|
||||
@moduledoc """
|
||||
Unit tests for the MemberFilterComponent.
|
||||
|
||||
Tests cover:
|
||||
- Rendering Payment Filter and Boolean Custom Fields
|
||||
- Boolean filter selection and event emission
|
||||
- Button label and badge logic
|
||||
- Filtering to show only boolean custom fields
|
||||
"""
|
||||
# async: false to prevent PostgreSQL deadlocks when running LiveView tests against DB
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Membership.CustomField
|
||||
|
||||
# Helper to create a boolean custom field
|
||||
defp create_boolean_custom_field(attrs \\ %{}) do
|
||||
default_attrs = %{
|
||||
name: "test_boolean_#{System.unique_integer([:positive])}",
|
||||
value_type: :boolean
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
# Helper to create a non-boolean custom field
|
||||
defp create_string_custom_field(attrs \\ %{}) do
|
||||
default_attrs = %{
|
||||
name: "test_string_#{System.unique_integer([:positive])}",
|
||||
value_type: :string
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
describe "rendering" do
|
||||
test "renders boolean custom fields when present", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
boolean_field = create_boolean_custom_field(%{name: "Active Member"})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Should show the boolean custom field name in the dropdown
|
||||
view
|
||||
|> element("#member-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
assert html =~ boolean_field.name
|
||||
end
|
||||
|
||||
test "renders payment and custom fields groups when boolean fields exist", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
_boolean_field = create_boolean_custom_field()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("#member-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
# Should have both "Payments" and "Custom Fields" group labels
|
||||
assert html =~ gettext("Payments") || html =~ "Payment"
|
||||
assert html =~ gettext("Custom Fields")
|
||||
end
|
||||
|
||||
test "renders only payment filter when no boolean custom fields exist", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
# Create a non-boolean field to ensure it's not shown
|
||||
_string_field = create_string_custom_field()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Component should exist with correct ID
|
||||
assert has_element?(view, "#member-filter")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("#member-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Should show payment filter options (check both English and translated)
|
||||
assert html =~ "All" || html =~ gettext("All")
|
||||
assert html =~ "Paid" || html =~ gettext("Paid")
|
||||
assert html =~ "Unpaid" || html =~ gettext("Unpaid")
|
||||
|
||||
# Should not show any boolean field names (since none exist)
|
||||
# We can't easily check this without knowing field names, but the structure should be correct
|
||||
end
|
||||
end
|
||||
|
||||
describe "boolean filter selection" do
|
||||
test "selecting boolean filter sends correct event", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
boolean_field = create_boolean_custom_field(%{name: "Newsletter"})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("#member-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
# Select "True" option for the boolean field using radio input
|
||||
# Radio inputs trigger phx-change on the form, so we use render_change on the form
|
||||
view
|
||||
|> form("#member-filter form", %{
|
||||
"custom_boolean" => %{to_string(boolean_field.id) => "true"}
|
||||
})
|
||||
|> render_change()
|
||||
|
||||
# The event should be sent to the parent LiveView
|
||||
# We verify this by checking that the URL is updated
|
||||
assert_patch(view)
|
||||
end
|
||||
|
||||
test "payment filter still works after component extension", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
_boolean_field = create_boolean_custom_field()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("#member-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
# Select "Paid" option using radio input
|
||||
# Radio inputs trigger phx-change on the form, so we use render_change on the form
|
||||
view
|
||||
|> form("#member-filter form", %{"payment_filter" => "paid"})
|
||||
|> render_change()
|
||||
|
||||
# URL should be updated with cycle_status_filter=paid
|
||||
path = assert_patch(view)
|
||||
assert path =~ "cycle_status_filter=paid"
|
||||
end
|
||||
end
|
||||
|
||||
describe "button label" do
|
||||
test "shows active boolean filter names in button label", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
boolean_field1 = create_boolean_custom_field(%{name: "Active Member"})
|
||||
boolean_field2 = create_boolean_custom_field(%{name: "Newsletter"})
|
||||
|
||||
# Set filters via URL
|
||||
{:ok, view, _html} =
|
||||
live(
|
||||
conn,
|
||||
"/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
|
||||
)
|
||||
|
||||
# Component should exist
|
||||
assert has_element?(view, "#member-filter")
|
||||
|
||||
# Button label should contain the custom field names
|
||||
# The exact format depends on implementation, but should show active filters
|
||||
button_html =
|
||||
view
|
||||
|> element("#member-filter button[aria-haspopup='true']")
|
||||
|> render()
|
||||
|
||||
assert button_html =~ boolean_field1.name || button_html =~ boolean_field2.name
|
||||
end
|
||||
|
||||
test "truncates long custom field names in button label", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
# Create field with very long name (>30 characters)
|
||||
long_name = String.duplicate("A", 50)
|
||||
boolean_field = create_boolean_custom_field(%{name: long_name})
|
||||
|
||||
# Set filter via URL
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/members?bf_#{boolean_field.id}=true")
|
||||
|
||||
# Component should exist
|
||||
assert has_element?(view, "#member-filter")
|
||||
|
||||
# Get button label text
|
||||
button_html =
|
||||
view
|
||||
|> element("#member-filter button[aria-haspopup='true']")
|
||||
|> render()
|
||||
|
||||
# Button label should be truncated - full name should NOT appear in button
|
||||
# (it may appear in dropdown when opened, but not in the button label itself)
|
||||
# Check that button doesn't contain the full 50-character name
|
||||
refute button_html =~ long_name
|
||||
|
||||
# Button should still contain some text (truncated version or indicator)
|
||||
assert String.length(button_html) > 0
|
||||
end
|
||||
end
|
||||
|
||||
describe "badge" do
|
||||
test "shows total count of active boolean filters in badge", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
boolean_field1 = create_boolean_custom_field(%{name: "Field1"})
|
||||
boolean_field2 = create_boolean_custom_field(%{name: "Field2"})
|
||||
|
||||
# Set two filters via URL
|
||||
{:ok, view, _html} =
|
||||
live(
|
||||
conn,
|
||||
"/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
|
||||
)
|
||||
|
||||
# Component should exist
|
||||
assert has_element?(view, "#member-filter")
|
||||
|
||||
# Badge should be visible when boolean filters are active
|
||||
assert has_element?(view, "#member-filter .badge")
|
||||
|
||||
# Badge should show count of active boolean filters (2 in this case)
|
||||
badge_html =
|
||||
view
|
||||
|> element("#member-filter .badge")
|
||||
|> render()
|
||||
|
||||
assert badge_html =~ "2"
|
||||
end
|
||||
end
|
||||
|
||||
describe "filtering" do
|
||||
test "only boolean custom fields are displayed, not string or integer fields", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
boolean_field = create_boolean_custom_field(%{name: "Boolean Field"})
|
||||
_string_field = create_string_custom_field(%{name: "String Field"})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("#member-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
# Should show boolean field in the dropdown panel
|
||||
# Extract only the dropdown panel HTML to check
|
||||
dropdown_html =
|
||||
view
|
||||
|> element("#member-filter div[role='dialog']")
|
||||
|> render()
|
||||
|
||||
# Should show boolean field in dropdown
|
||||
assert dropdown_html =~ boolean_field.name
|
||||
|
||||
# Should not show string field name in the filter dropdown
|
||||
# (String fields should not appear in boolean filter section)
|
||||
refute dropdown_html =~ "String Field"
|
||||
end
|
||||
|
||||
test "dropdown shows scrollbar when many boolean custom fields exist", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
# Create 15 boolean custom fields (more than typical, should trigger scrollbar)
|
||||
boolean_fields =
|
||||
Enum.map(1..15, fn i ->
|
||||
create_boolean_custom_field(%{name: "Field #{i}"})
|
||||
end)
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("#member-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
# Extract dropdown panel HTML
|
||||
dropdown_html =
|
||||
view
|
||||
|> element("#member-filter div[role='dialog']")
|
||||
|> render()
|
||||
|
||||
# Should have scrollbar classes: max-h-60 overflow-y-auto pr-2
|
||||
# Check for the scrollable container (the div with max-h-60 and overflow-y-auto)
|
||||
assert dropdown_html =~ "max-h-60"
|
||||
assert dropdown_html =~ "overflow-y-auto"
|
||||
|
||||
# Verify all fields are present in the dropdown
|
||||
Enum.each(boolean_fields, fn field ->
|
||||
assert dropdown_html =~ field.name
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
defmodule MvWeb.Components.PaymentFilterComponentTest do
|
||||
@moduledoc """
|
||||
Unit tests for the PaymentFilterComponent.
|
||||
|
||||
Tests cover:
|
||||
- Rendering in all 3 filter states (nil, :paid, :unpaid)
|
||||
- Event emission when selecting options
|
||||
- ARIA attributes for accessibility
|
||||
- Dropdown open/close behavior
|
||||
"""
|
||||
# async: false to prevent PostgreSQL deadlocks when running LiveView tests against DB
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
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?cycle_status_filter=paid")
|
||||
|
||||
# Should show badge when filter is active
|
||||
assert has_element?(view, "#payment-filter .badge")
|
||||
end
|
||||
|
||||
test "renders with unpaid filter active", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=unpaid")
|
||||
|
||||
# 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?cycle_status_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 cycle_status_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 cycle_status_filter=paid
|
||||
path = assert_patch(view)
|
||||
assert path =~ "cycle_status_filter=paid"
|
||||
end
|
||||
|
||||
test "selecting 'Unpaid' 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 "Unpaid" option
|
||||
view
|
||||
|> element("#payment-filter button[phx-value-filter='unpaid']")
|
||||
|> render_click()
|
||||
|
||||
# Wait for patch and check URL contains cycle_status_filter=unpaid
|
||||
path = assert_patch(view)
|
||||
assert path =~ "cycle_status_filter=unpaid"
|
||||
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?cycle_status_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
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue