Merge pull request 'Add boolean custom field filters to member overview closes #309' (#362) from feature/filter-boolean-custom-fields 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: #362
This commit is contained in:
commit
b6992f8488
11 changed files with 2725 additions and 433 deletions
|
|
@ -19,6 +19,12 @@ defmodule Mv.Constants do
|
||||||
|
|
||||||
@custom_field_prefix "custom_field_"
|
@custom_field_prefix "custom_field_"
|
||||||
|
|
||||||
|
@boolean_filter_prefix "bf_"
|
||||||
|
|
||||||
|
@max_boolean_filters 50
|
||||||
|
|
||||||
|
@max_uuid_length 36
|
||||||
|
|
||||||
@email_validator_checks [:html_input, :pow]
|
@email_validator_checks [:html_input, :pow]
|
||||||
|
|
||||||
def member_fields, do: @member_fields
|
def member_fields, do: @member_fields
|
||||||
|
|
@ -33,6 +39,42 @@ defmodule Mv.Constants do
|
||||||
"""
|
"""
|
||||||
def custom_field_prefix, do: @custom_field_prefix
|
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 """
|
@doc """
|
||||||
Returns the email validator checks used for EctoCommons.EmailValidator.
|
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
|
use MvWeb, :live_view
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
require Logger
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
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>")
|
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
|
||||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
@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
|
# Member fields that are loaded for the overview
|
||||||
# Uses constants from Mv.Constants to ensure consistency
|
# Uses constants from Mv.Constants to ensure consistency
|
||||||
# Note: :id is always included for member identification
|
# Note: :id is always included for member identification
|
||||||
|
|
@ -72,6 +82,12 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> Ash.Query.sort(name: :asc)
|
|> Ash.Query.sort(name: :asc)
|
||||||
|> Ash.read!(actor: actor)
|
|> 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
|
# Load settings once to avoid N+1 queries
|
||||||
settings =
|
settings =
|
||||||
case Membership.get_settings() do
|
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_field, fn -> :first_name end)
|
||||||
|> assign_new(:sort_order, fn -> :asc end)
|
|> assign_new(:sort_order, fn -> :asc end)
|
||||||
|> assign(:cycle_status_filter, nil)
|
|> assign(:cycle_status_filter, nil)
|
||||||
|
|> assign(:boolean_custom_field_filters, %{})
|
||||||
|> assign(:selected_members, MapSet.new())
|
|> assign(:selected_members, MapSet.new())
|
||||||
|> assign(:settings, settings)
|
|> assign(:settings, settings)
|
||||||
|> assign(:custom_fields_visible, custom_fields_visible)
|
|> assign(:custom_fields_visible, custom_fields_visible)
|
||||||
|> assign(:all_custom_fields, all_custom_fields)
|
|> assign(:all_custom_fields, all_custom_fields)
|
||||||
|
|> assign(:boolean_custom_fields, boolean_custom_fields)
|
||||||
|> assign(:all_available_fields, all_available_fields)
|
|> assign(:all_available_fields, all_available_fields)
|
||||||
|> assign(:user_field_selection, initial_selection)
|
|> assign(:user_field_selection, initial_selection)
|
||||||
|> assign(
|
|> assign(
|
||||||
|
|
@ -218,7 +236,8 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.sort_field,
|
socket.assigns.sort_field,
|
||||||
socket.assigns.sort_order,
|
socket.assigns.sort_order,
|
||||||
socket.assigns.cycle_status_filter,
|
socket.assigns.cycle_status_filter,
|
||||||
new_show_current
|
new_show_current,
|
||||||
|
socket.assigns.boolean_custom_field_filters
|
||||||
)
|
)
|
||||||
|
|
||||||
new_path = ~p"/members?#{query_params}"
|
new_path = ~p"/members?#{query_params}"
|
||||||
|
|
@ -332,7 +351,8 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
existing_field_query,
|
existing_field_query,
|
||||||
existing_sort_query,
|
existing_sort_query,
|
||||||
socket.assigns.cycle_status_filter,
|
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
|
# Set the new path with params
|
||||||
|
|
@ -361,7 +381,77 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.sort_field,
|
socket.assigns.sort_field,
|
||||||
socket.assigns.sort_order,
|
socket.assigns.sort_order,
|
||||||
filter,
|
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}"
|
new_path = ~p"/members?#{query_params}"
|
||||||
|
|
@ -448,6 +538,9 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
"""
|
"""
|
||||||
@impl true
|
@impl true
|
||||||
def handle_params(params, _url, socket) do
|
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
|
# Parse field selection from URL
|
||||||
url_selection = FieldSelection.parse_from_url(params)
|
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_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
|
||||||
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
|
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
|
||||||
|
|
||||||
|
# Apply all updates
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> maybe_update_search(params)
|
|> maybe_update_search(params)
|
||||||
|> maybe_update_sort(params)
|
|> maybe_update_sort(params)
|
||||||
|> maybe_update_cycle_status_filter(params)
|
|> maybe_update_cycle_status_filter(params)
|
||||||
|
|> maybe_update_boolean_filters(params)
|
||||||
|> maybe_update_show_current_cycle(params)
|
|> maybe_update_show_current_cycle(params)
|
||||||
|> assign(:query, params["query"])
|
|> assign(:query, params["query"])
|
||||||
|> assign(:user_field_selection, final_selection)
|
|> assign(:user_field_selection, final_selection)
|
||||||
|> assign(:member_fields_visible, visible_member_fields)
|
|> assign(:member_fields_visible, visible_member_fields)
|
||||||
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
||||||
|> load_members()
|
|
||||||
|> prepare_dynamic_cols()
|
# Build signature AFTER updates
|
||||||
|> update_selection_assigns()
|
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}
|
{:noreply, socket}
|
||||||
end
|
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.
|
# Prepares dynamic column definitions for custom fields that should be shown in the overview.
|
||||||
#
|
#
|
||||||
# Creates a list of column definitions, each containing:
|
# Creates a list of column definitions, each containing:
|
||||||
|
|
@ -586,7 +724,8 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
field_str,
|
field_str,
|
||||||
Atom.to_string(order),
|
Atom.to_string(order),
|
||||||
socket.assigns.cycle_status_filter,
|
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}"
|
new_path = ~p"/members?#{query_params}"
|
||||||
|
|
@ -616,7 +755,8 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.sort_field,
|
socket.assigns.sort_field,
|
||||||
socket.assigns.sort_order,
|
socket.assigns.sort_order,
|
||||||
socket.assigns.cycle_status_filter,
|
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])
|
|> 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.
|
# Builds URL query parameters map including all filter/sort state.
|
||||||
# Converts cycle_status_filter atom to string for URL.
|
# Converts cycle_status_filter atom to string for URL.
|
||||||
|
# Adds boolean custom field filters as bf_<id>=true|false.
|
||||||
defp build_query_params(
|
defp build_query_params(
|
||||||
query,
|
query,
|
||||||
sort_field,
|
sort_field,
|
||||||
sort_order,
|
sort_order,
|
||||||
cycle_status_filter,
|
cycle_status_filter,
|
||||||
show_current_cycle
|
show_current_cycle,
|
||||||
|
boolean_filters
|
||||||
) do
|
) do
|
||||||
field_str =
|
field_str =
|
||||||
if is_atom(sort_field) do
|
if is_atom(sort_field) do
|
||||||
|
|
@ -670,11 +812,19 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Add show_current_cycle if true
|
# Add show_current_cycle if true
|
||||||
if show_current_cycle do
|
base_params =
|
||||||
Map.put(base_params, "show_current_cycle", "true")
|
if show_current_cycle do
|
||||||
else
|
Map.put(base_params, "show_current_cycle", "true")
|
||||||
base_params
|
else
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
# Loads members from the database with custom field values and applies search/sort/payment filters.
|
# 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.new()
|
||||||
|> Ash.Query.select(@overview_fields)
|
|> 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] || []
|
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
|
# Load membership fee cycles for status display
|
||||||
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
|
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
|
# Errors in handle_params are handled by Phoenix LiveView
|
||||||
actor = current_actor(socket)
|
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
|
# Custom field values are already filtered at the database level in load_custom_field_values/2
|
||||||
# No need for in-memory filtering anymore
|
# No need for in-memory filtering anymore
|
||||||
|
|
@ -739,6 +914,14 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.show_current_cycle
|
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)
|
# Sort in memory if needed (for custom fields)
|
||||||
members =
|
members =
|
||||||
if sort_after_load do
|
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("unpaid"), do: :unpaid
|
||||||
defp determine_cycle_status_filter(_), do: nil
|
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.
|
# Updates show_current_cycle from URL parameters if present.
|
||||||
defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}) do
|
defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}) do
|
||||||
assign(socket, :show_current_cycle, true)
|
assign(socket, :show_current_cycle, true)
|
||||||
|
|
@ -1166,7 +1485,166 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
values when is_list(values) ->
|
values when is_list(values) ->
|
||||||
Enum.find(values, fn cfv ->
|
Enum.find(values, fn cfv ->
|
||||||
cfv.custom_field_id == custom_field.id or
|
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)
|
end)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
|
|
@ -1221,8 +1699,11 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
#
|
#
|
||||||
# Note: Mailto URLs have length limits that vary by email client.
|
# Note: Mailto URLs have length limits that vary by email client.
|
||||||
# For large selections, consider using export functionality instead.
|
# 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
|
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_members = socket.assigns.selected_members
|
||||||
|
|
||||||
selected_count =
|
selected_count =
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,11 @@
|
||||||
placeholder={gettext("Search...")}
|
placeholder={gettext("Search...")}
|
||||||
/>
|
/>
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.PaymentFilterComponent}
|
module={MvWeb.Components.MemberFilterComponent}
|
||||||
id="payment-filter"
|
id="member-filter"
|
||||||
cycle_status_filter={@cycle_status_filter}
|
cycle_status_filter={@cycle_status_filter}
|
||||||
|
boolean_custom_fields={@boolean_custom_fields}
|
||||||
|
boolean_filters={@boolean_custom_field_filters}
|
||||||
member_count={length(@members)}
|
member_count={length(@members)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ msgstr "Hausnummer"
|
||||||
msgid "Notes"
|
msgid "Notes"
|
||||||
msgstr "Notizen"
|
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.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||||
|
|
@ -182,6 +182,7 @@ msgstr "Speichern..."
|
||||||
msgid "Street"
|
msgid "Street"
|
||||||
msgstr "Straße"
|
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/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||||
|
|
@ -196,6 +197,7 @@ msgstr "Nein"
|
||||||
msgid "Show Member"
|
msgid "Show Member"
|
||||||
msgstr "Mitglied anzeigen"
|
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/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/member_live/index/formatter.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."
|
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."
|
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/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -739,21 +742,11 @@ msgid "This field cannot be empty"
|
||||||
msgstr "Dieses Feld darf nicht leer bleiben"
|
msgstr "Dieses Feld darf nicht leer bleiben"
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
#: 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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "All"
|
msgid "All"
|
||||||
msgstr "Alle"
|
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
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Address"
|
msgid "Address"
|
||||||
|
|
@ -786,6 +779,7 @@ msgstr "Nr."
|
||||||
msgid "Payment Data"
|
msgid "Payment Data"
|
||||||
msgstr "Beitragsdaten"
|
msgstr "Beitragsdaten"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Payments"
|
msgid "Payments"
|
||||||
|
|
@ -920,7 +914,7 @@ msgstr "Status"
|
||||||
msgid "Suspended"
|
msgid "Suspended"
|
||||||
msgstr "Pausiert"
|
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.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||||
|
|
@ -1926,6 +1920,31 @@ msgstr "Validierung fehlgeschlagen: %{field} %{message}"
|
||||||
msgid "Validation failed: %{message}"
|
msgid "Validation failed: %{message}"
|
||||||
msgstr "Validierung fehlgeschlagen: %{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
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Only administrators can regenerate cycles"
|
msgid "Only administrators can regenerate cycles"
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,7 @@ msgstr ""
|
||||||
msgid "Notes"
|
msgid "Notes"
|
||||||
msgstr ""
|
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.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||||
|
|
@ -183,6 +183,7 @@ msgstr ""
|
||||||
msgid "Street"
|
msgid "Street"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/custom_field_live/index_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_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||||
|
|
@ -197,6 +198,7 @@ msgstr ""
|
||||||
msgid "Show Member"
|
msgid "Show Member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/custom_field_live/index_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_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/member_live/index/formatter.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."
|
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -740,21 +743,11 @@ msgid "This field cannot be empty"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
#: 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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "All"
|
msgid "All"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Address"
|
msgid "Address"
|
||||||
|
|
@ -787,6 +780,7 @@ msgstr ""
|
||||||
msgid "Payment Data"
|
msgid "Payment Data"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Payments"
|
msgid "Payments"
|
||||||
|
|
@ -921,7 +915,7 @@ msgstr ""
|
||||||
msgid "Suspended"
|
msgid "Suspended"
|
||||||
msgstr ""
|
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.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||||
|
|
@ -1927,6 +1921,31 @@ msgstr ""
|
||||||
msgid "Validation failed: %{message}"
|
msgid "Validation failed: %{message}"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Only administrators can regenerate cycles"
|
msgid "Only administrators can regenerate cycles"
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,7 @@ msgstr ""
|
||||||
msgid "Notes"
|
msgid "Notes"
|
||||||
msgstr ""
|
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.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||||
|
|
@ -183,6 +183,7 @@ msgstr ""
|
||||||
msgid "Street"
|
msgid "Street"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/custom_field_live/index_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_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||||
|
|
@ -197,6 +198,7 @@ msgstr ""
|
||||||
msgid "Show Member"
|
msgid "Show Member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/custom_field_live/index_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_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/member_live/index/formatter.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."
|
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
|
@ -740,21 +743,11 @@ msgid "This field cannot be empty"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
#: 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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "All"
|
msgid "All"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Address"
|
msgid "Address"
|
||||||
|
|
@ -787,6 +780,7 @@ msgstr ""
|
||||||
msgid "Payment Data"
|
msgid "Payment Data"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Payments"
|
msgid "Payments"
|
||||||
|
|
@ -921,7 +915,7 @@ msgstr ""
|
||||||
msgid "Suspended"
|
msgid "Suspended"
|
||||||
msgstr ""
|
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.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||||
|
|
@ -1927,7 +1921,339 @@ msgstr ""
|
||||||
msgid "Validation failed: %{message}"
|
msgid "Validation failed: %{message}"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Only administrators can regenerate cycles"
|
msgid "Only administrators can regenerate cycles"
|
||||||
msgstr ""
|
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