feat(member-filter): add date filter sections with active-count badge and reset support
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
parent
e3295ab4b5
commit
d6671daf1a
7 changed files with 834 additions and 18 deletions
|
|
@ -23,6 +23,11 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
- `:fee_type_filters` - Map of active fee type filters: `%{fee_type_id => :in | :not_in}` (nil = All).
|
||||
- `:boolean_custom_fields` - List of boolean custom fields to display
|
||||
- `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}`
|
||||
- `:date_custom_fields` - List of date-typed custom fields rendered in the
|
||||
"Custom date fields" section (each with `:id`, `:name`, `:value_type`).
|
||||
- `:date_filters` - Date filter state map (see `MvWeb.MemberLive.Index.DateFilter`):
|
||||
built-in `:join_date` / `:exit_date` bounds and mode, plus optional
|
||||
UUID-keyed custom date field bound entries.
|
||||
- `:id` - Component ID (required)
|
||||
- `:member_count` - Number of filtered members to display in badge (optional, default: 0)
|
||||
|
||||
|
|
@ -31,13 +36,18 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
- Sends `{:group_filter_changed, group_id_str, value}` to parent when a group filter changes (value: nil | :in | :not_in)
|
||||
- Sends `{:fee_type_filter_changed, fee_type_id_str, value}` to parent when a fee type filter changes (value: nil | :in | :not_in)
|
||||
- Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes
|
||||
- Sends `{:date_filters_changed, new_filters}` to parent when any date
|
||||
filter input changes (built-in date bounds, exit_date mode, or custom
|
||||
date field bounds).
|
||||
"""
|
||||
use MvWeb, :live_component
|
||||
|
||||
alias MvWeb.MemberLive.Index.DateFilter
|
||||
alias MvWeb.MemberLive.Index.FilterParams
|
||||
|
||||
@group_filter_prefix Mv.Constants.group_filter_prefix()
|
||||
@fee_type_filter_prefix Mv.Constants.fee_type_filter_prefix()
|
||||
@custom_date_filter_prefix Mv.Constants.custom_date_filter_prefix()
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
|
|
@ -50,19 +60,42 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
socket
|
||||
|> assign(:id, assigns.id)
|
||||
|> assign(:cycle_status_filter, assigns[:cycle_status_filter])
|
||||
|> assign(:groups, assigns[:groups] || [])
|
||||
|> assign(:group_filters, assigns[:group_filters] || %{})
|
||||
|> assign(:group_filter_prefix, @group_filter_prefix)
|
||||
|> assign(:fee_types, assigns[:fee_types] || [])
|
||||
|> assign(:fee_type_filters, assigns[:fee_type_filters] || %{})
|
||||
|> assign(:fee_type_filter_prefix, @fee_type_filter_prefix)
|
||||
|> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || [])
|
||||
|> assign(:boolean_filters, assigns[:boolean_filters] || %{})
|
||||
|> assign_group_assigns(assigns)
|
||||
|> assign_fee_type_assigns(assigns)
|
||||
|> assign_boolean_assigns(assigns)
|
||||
|> assign_date_assigns(assigns)
|
||||
|> assign(:member_count, assigns[:member_count] || 0)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
defp assign_group_assigns(socket, assigns) do
|
||||
socket
|
||||
|> assign(:groups, assigns[:groups] || [])
|
||||
|> assign(:group_filters, assigns[:group_filters] || %{})
|
||||
|> assign(:group_filter_prefix, @group_filter_prefix)
|
||||
end
|
||||
|
||||
defp assign_fee_type_assigns(socket, assigns) do
|
||||
socket
|
||||
|> assign(:fee_types, assigns[:fee_types] || [])
|
||||
|> assign(:fee_type_filters, assigns[:fee_type_filters] || %{})
|
||||
|> assign(:fee_type_filter_prefix, @fee_type_filter_prefix)
|
||||
end
|
||||
|
||||
defp assign_boolean_assigns(socket, assigns) do
|
||||
socket
|
||||
|> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || [])
|
||||
|> assign(:boolean_filters, assigns[:boolean_filters] || %{})
|
||||
end
|
||||
|
||||
defp assign_date_assigns(socket, assigns) do
|
||||
socket
|
||||
|> assign(:date_custom_fields, assigns[:date_custom_fields] || [])
|
||||
|> assign(:date_filters, assigns[:date_filters] || DateFilter.default())
|
||||
|> assign(:custom_date_filter_prefix, @custom_date_filter_prefix)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
|
@ -81,7 +114,8 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
"gap-2",
|
||||
(@cycle_status_filter || map_size(@group_filters) > 0 ||
|
||||
map_size(@fee_type_filters) > 0 ||
|
||||
active_boolean_filters_count(@boolean_filters) > 0) &&
|
||||
active_boolean_filters_count(@boolean_filters) > 0 ||
|
||||
date_filters_active?(@date_filters)) &&
|
||||
"btn-active"
|
||||
]}
|
||||
phx-click="toggle_dropdown"
|
||||
|
|
@ -99,7 +133,8 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
@fee_types,
|
||||
@fee_type_filters,
|
||||
@boolean_custom_fields,
|
||||
@boolean_filters
|
||||
@boolean_filters,
|
||||
@date_filters
|
||||
)}
|
||||
</span>
|
||||
<.badge
|
||||
|
|
@ -111,7 +146,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
</.badge>
|
||||
<.badge
|
||||
:if={
|
||||
(@cycle_status_filter || map_size(@group_filters) > 0 || map_size(@fee_type_filters) > 0) &&
|
||||
(@cycle_status_filter || map_size(@group_filters) > 0 ||
|
||||
map_size(@fee_type_filters) > 0 ||
|
||||
date_filters_active?(@date_filters)) &&
|
||||
active_boolean_filters_count(@boolean_filters) == 0
|
||||
}
|
||||
variant="primary"
|
||||
|
|
@ -329,6 +366,163 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dates Group (built-in fields: exit_date with mode selector, join_date range) -->
|
||||
<div class="mb-4">
|
||||
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
|
||||
{gettext("Dates")}
|
||||
</div>
|
||||
<fieldset class="border-0 p-0 m-0 min-w-0 mb-3">
|
||||
<legend class="text-sm font-medium mb-1">
|
||||
{gettext("Exit date")}
|
||||
</legend>
|
||||
<div class="join w-full">
|
||||
<label
|
||||
class={"#{exit_mode_label_class(@date_filters, :active_only)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
|
||||
for="ed-mode-active-only"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
id="ed-mode-active-only"
|
||||
name="ed_mode"
|
||||
value="active_only"
|
||||
class="absolute opacity-0 w-0 h-0 pointer-events-none"
|
||||
checked={exit_mode(@date_filters) == :active_only}
|
||||
/>
|
||||
<span class="text-xs">{gettext("Active only")}</span>
|
||||
</label>
|
||||
<label
|
||||
class={"#{exit_mode_label_class(@date_filters, :all)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
|
||||
for="ed-mode-all"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
id="ed-mode-all"
|
||||
name="ed_mode"
|
||||
value="all"
|
||||
class="absolute opacity-0 w-0 h-0 pointer-events-none"
|
||||
checked={exit_mode(@date_filters) == :all}
|
||||
/>
|
||||
<span class="text-xs">{gettext("All")}</span>
|
||||
</label>
|
||||
<label
|
||||
class={"#{exit_mode_label_class(@date_filters, :inactive_only)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
|
||||
for="ed-mode-inactive-only"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
id="ed-mode-inactive-only"
|
||||
name="ed_mode"
|
||||
value="inactive_only"
|
||||
class="absolute opacity-0 w-0 h-0 pointer-events-none"
|
||||
checked={exit_mode(@date_filters) == :inactive_only}
|
||||
/>
|
||||
<span class="text-xs">{gettext("Inactive only")}</span>
|
||||
</label>
|
||||
<label
|
||||
class={"#{exit_mode_label_class(@date_filters, :custom)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
|
||||
for="ed-mode-custom"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
id="ed-mode-custom"
|
||||
name="ed_mode"
|
||||
value="custom"
|
||||
class="absolute opacity-0 w-0 h-0 pointer-events-none"
|
||||
checked={exit_mode(@date_filters) == :custom}
|
||||
/>
|
||||
<span class="text-xs">{gettext("Range")}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
:if={exit_mode(@date_filters) == :custom}
|
||||
class="mt-2 flex gap-3 items-end flex-wrap"
|
||||
>
|
||||
<.input
|
||||
type="date"
|
||||
id="ed-from"
|
||||
name="ed_from"
|
||||
label={gettext("From")}
|
||||
class="input input-sm input-bordered"
|
||||
aria-label={gettext("Exit date from")}
|
||||
value={date_value_for_input(@date_filters, :exit_date, :from)}
|
||||
/>
|
||||
<.input
|
||||
type="date"
|
||||
id="ed-to"
|
||||
name="ed_to"
|
||||
label={gettext("To")}
|
||||
class="input input-sm input-bordered"
|
||||
aria-label={gettext("Exit date to")}
|
||||
value={date_value_for_input(@date_filters, :exit_date, :to)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="border-0 p-0 m-0 min-w-0">
|
||||
<legend class="text-sm font-medium mb-1">
|
||||
{gettext("Join date")}
|
||||
</legend>
|
||||
<div class="flex gap-3 items-end flex-wrap">
|
||||
<.input
|
||||
type="date"
|
||||
id="jd-from"
|
||||
name="jd_from"
|
||||
label={gettext("From")}
|
||||
class="input input-sm input-bordered"
|
||||
aria-label={gettext("Join date from")}
|
||||
value={date_value_for_input(@date_filters, :join_date, :from)}
|
||||
/>
|
||||
<.input
|
||||
type="date"
|
||||
id="jd-to"
|
||||
name="jd_to"
|
||||
label={gettext("To")}
|
||||
class="input input-sm input-bordered"
|
||||
aria-label={gettext("Join date to")}
|
||||
value={date_value_for_input(@date_filters, :join_date, :to)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<!-- Custom Date Fields Group (in-memory filter; one row per :date custom field) -->
|
||||
<div :if={length(@date_custom_fields) > 0} class="mb-4">
|
||||
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
|
||||
{gettext("Custom date fields")}
|
||||
</div>
|
||||
<div class={
|
||||
if length(@date_custom_fields) > 5, do: "max-h-60 overflow-y-auto pr-2", else: ""
|
||||
}>
|
||||
<fieldset
|
||||
:for={field <- @date_custom_fields}
|
||||
class="border-0 border-b border-base-200 last:border-b-0 p-0 m-0 min-w-0 py-2"
|
||||
>
|
||||
<legend class="text-sm font-medium mb-1">
|
||||
{field.name}
|
||||
</legend>
|
||||
<div class="flex gap-3 items-end flex-wrap">
|
||||
<.input
|
||||
type="date"
|
||||
id={"cdf-#{field.id}-from"}
|
||||
name={"#{@custom_date_filter_prefix}#{field.id}_from"}
|
||||
label={gettext("From")}
|
||||
class="input input-sm input-bordered"
|
||||
aria-label={gettext("%{field} from", field: field.name)}
|
||||
value={custom_date_value_for_input(@date_filters, field.id, :from)}
|
||||
/>
|
||||
<.input
|
||||
type="date"
|
||||
id={"cdf-#{field.id}-to"}
|
||||
name={"#{@custom_date_filter_prefix}#{field.id}_to"}
|
||||
label={gettext("To")}
|
||||
class="input input-sm input-bordered"
|
||||
aria-label={gettext("%{field} to", field: field.name)}
|
||||
value={custom_date_value_for_input(@date_filters, field.id, :to)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</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">
|
||||
|
|
@ -452,11 +646,13 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
)
|
||||
|
||||
custom_boolean_filters_parsed = parse_custom_boolean_filters(params)
|
||||
new_date_filters = DateFilter.from_params(params, socket.assigns.date_custom_fields)
|
||||
|
||||
dispatch_payment_filter_change(socket, payment_filter)
|
||||
dispatch_group_filter_changes(socket, group_filters_parsed)
|
||||
dispatch_fee_type_filter_changes(socket, fee_type_filters_parsed)
|
||||
dispatch_boolean_filter_changes(socket, custom_boolean_filters_parsed)
|
||||
dispatch_date_filters_change(socket, new_date_filters)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
|
@ -540,6 +736,12 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
end)
|
||||
end
|
||||
|
||||
defp dispatch_date_filters_change(socket, new_date_filters) do
|
||||
if new_date_filters != socket.assigns.date_filters do
|
||||
send(self(), {:date_filters_changed, new_date_filters})
|
||||
end
|
||||
end
|
||||
|
||||
# Get display label for button
|
||||
defp button_label(
|
||||
cycle_status_filter,
|
||||
|
|
@ -548,14 +750,16 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
fee_types,
|
||||
fee_type_filters,
|
||||
boolean_custom_fields,
|
||||
boolean_filters
|
||||
boolean_filters,
|
||||
date_filters
|
||||
) do
|
||||
active_count =
|
||||
count_active_filter_categories(
|
||||
cycle_status_filter,
|
||||
group_filters,
|
||||
fee_type_filters,
|
||||
boolean_filters
|
||||
boolean_filters,
|
||||
date_filters
|
||||
)
|
||||
|
||||
if active_count >= 2 do
|
||||
|
|
@ -576,6 +780,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
map_size(boolean_filters) > 0 ->
|
||||
boolean_filter_label(boolean_custom_fields, boolean_filters)
|
||||
|
||||
date_filters_active?(date_filters) ->
|
||||
gettext("Dates")
|
||||
|
||||
true ->
|
||||
gettext("Apply filters")
|
||||
end
|
||||
|
|
@ -586,17 +793,27 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
cycle_status_filter,
|
||||
group_filters,
|
||||
fee_type_filters,
|
||||
boolean_filters
|
||||
boolean_filters,
|
||||
date_filters
|
||||
) do
|
||||
[
|
||||
cycle_status_filter,
|
||||
map_size(group_filters) > 0,
|
||||
map_size(fee_type_filters) > 0,
|
||||
map_size(boolean_filters) > 0
|
||||
map_size(boolean_filters) > 0,
|
||||
date_filters_active?(date_filters)
|
||||
]
|
||||
|> Enum.count(& &1)
|
||||
end
|
||||
|
||||
# Date filter is "active" when its state differs from the default — i.e. the
|
||||
# user selected something other than active-only with no custom date bounds.
|
||||
defp date_filters_active?(date_filters) when is_map(date_filters) do
|
||||
date_filters != DateFilter.default()
|
||||
end
|
||||
|
||||
defp date_filters_active?(_), do: false
|
||||
|
||||
defp group_filters_label(_groups, group_filters) when map_size(group_filters) == 0,
|
||||
do: gettext("All")
|
||||
|
||||
|
|
@ -765,4 +982,35 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
"#{base_classes} btn-outline"
|
||||
end
|
||||
end
|
||||
|
||||
# --- Date filter helpers ----------------------------------------------
|
||||
|
||||
defp exit_mode(%{exit_date: %{mode: mode}}), do: mode
|
||||
defp exit_mode(_), do: :active_only
|
||||
|
||||
defp exit_mode_label_class(date_filters, expected) do
|
||||
base_classes = "join-item btn btn-sm"
|
||||
|
||||
if exit_mode(date_filters) == expected do
|
||||
"#{base_classes} btn-active"
|
||||
else
|
||||
"#{base_classes} btn"
|
||||
end
|
||||
end
|
||||
|
||||
defp date_value_for_input(date_filters, field, bound) do
|
||||
case date_filters do
|
||||
%{^field => %{^bound => %Date{} = d}} -> Date.to_iso8601(d)
|
||||
_ -> ""
|
||||
end
|
||||
end
|
||||
|
||||
defp custom_date_value_for_input(date_filters, field_id, bound) do
|
||||
key = to_string(field_id)
|
||||
|
||||
case Map.get(date_filters, key) do
|
||||
%{^bound => %Date{} = d} -> Date.to_iso8601(d)
|
||||
_ -> ""
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@
|
|||
fee_type_filters={@fee_type_filters}
|
||||
boolean_custom_fields={@boolean_custom_fields}
|
||||
boolean_filters={@boolean_custom_field_filters}
|
||||
date_custom_fields={@date_custom_fields}
|
||||
date_filters={@date_filters}
|
||||
member_count={length(@members)}
|
||||
/>
|
||||
<.tooltip
|
||||
|
|
|
|||
|
|
@ -3897,3 +3897,78 @@ msgstr "Die SMTP-Umgebungs-Konfiguration ist unvollständig. Fehlend: %{keys}"
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only."
|
||||
msgstr "SMTP wird vollständig über Umgebungsvariablen verwaltet. Alle SMTP-Felder sind schreibgeschützt."
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{field} from"
|
||||
msgstr "%{field} von"
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{field} to"
|
||||
msgstr "%{field} bis"
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Active only"
|
||||
msgstr "Nur aktive"
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom date fields"
|
||||
msgstr "Benutzerdefinierte Datumsfelder"
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Dates"
|
||||
msgstr "Daten"
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Exit date"
|
||||
msgstr "Austrittsdatum"
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Exit date from"
|
||||
msgstr "Austrittsdatum von"
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Exit date to"
|
||||
msgstr "Austrittsdatum bis"
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From"
|
||||
msgstr "Von"
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Inactive only"
|
||||
msgstr "Nur ehemalige"
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Join date"
|
||||
msgstr "Beitrittsdatum"
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Join date from"
|
||||
msgstr "Beitrittsdatum von"
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Join date to"
|
||||
msgstr "Beitrittsdatum bis"
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Range"
|
||||
msgstr "Zeitraum"
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "To"
|
||||
msgstr "Bis"
|
||||
|
|
|
|||
|
|
@ -3897,3 +3897,78 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{field} from"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{field} to"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Active only"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom date fields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Dates"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Exit date"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Exit date from"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Exit date to"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Inactive only"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Join date"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Join date from"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Join date to"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Range"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "To"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -3897,3 +3897,78 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "SMTP is fully managed via environment variables. All SMTP fields are read-only."
|
||||
msgstr "SMTP is fully managed via environment variables. All SMTP fields are read-only."
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{field} from"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{field} to"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Active only"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Custom date fields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Dates"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Exit date"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Exit date from"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Exit date to"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Inactive only"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Join date"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Join date from"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Join date to"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Range"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "To"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -209,6 +209,57 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
|
|||
# Button should still contain some text (truncated version or indicator)
|
||||
assert String.length(button_html) > 0
|
||||
end
|
||||
|
||||
test "date-only activation (ed_mode=all) replaces the idle label", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?ed_mode=all")
|
||||
|
||||
button_html =
|
||||
view
|
||||
|> element("#member-filter button[aria-haspopup='true']")
|
||||
|> render()
|
||||
|
||||
# The idle label must not appear; some non-idle label is shown. This is
|
||||
# the same observable contract as the other filter categories — the
|
||||
# button visually communicates "a filter is active". The `btn-active`
|
||||
# CSS class is set by the parent class= attribute but the `<.button>`
|
||||
# core component currently composes its own class string and drops the
|
||||
# caller-supplied one — that is a pre-existing component constraint, not
|
||||
# specific to date filters.
|
||||
refute button_html =~ gettext("Apply filters")
|
||||
end
|
||||
|
||||
test "date-only activation (jd_from) replaces the idle label", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?jd_from=2024-01-15")
|
||||
|
||||
button_html =
|
||||
view
|
||||
|> element("#member-filter button[aria-haspopup='true']")
|
||||
|> render()
|
||||
|
||||
refute button_html =~ gettext("Apply filters")
|
||||
end
|
||||
|
||||
test "date filter combined with one other filter shows '2 filters active'", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
boolean_field = create_boolean_custom_field(%{name: "Newsletter"})
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/members?ed_mode=all&bf_#{boolean_field.id}=true")
|
||||
|
||||
button_html =
|
||||
view
|
||||
|> element("#member-filter button[aria-haspopup='true']")
|
||||
|> render()
|
||||
|
||||
# With two distinct filter categories active, the label switches to the
|
||||
# pluralized "N filters active" form. Without counting date filters as
|
||||
# a category, this would show only "1 filter active" or the boolean
|
||||
# field name.
|
||||
assert button_html =~ "2"
|
||||
assert button_html =~ gettext("filters active")
|
||||
end
|
||||
end
|
||||
|
||||
describe "badge" do
|
||||
|
|
@ -268,6 +319,293 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
|
|||
refute dropdown_html =~ "String Field"
|
||||
end
|
||||
|
||||
test "renders the Dates section with exit_date and join_date controls", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
view
|
||||
|> element("#member-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
dropdown_html =
|
||||
view
|
||||
|> element("#member-filter div[role='dialog']")
|
||||
|> render()
|
||||
|
||||
assert dropdown_html =~ gettext("Dates")
|
||||
assert dropdown_html =~ gettext("Join date")
|
||||
assert dropdown_html =~ gettext("Exit date")
|
||||
# Exit-date segmented control modes.
|
||||
assert dropdown_html =~ gettext("Active only")
|
||||
assert dropdown_html =~ gettext("Inactive only")
|
||||
# Built-in date inputs (always present for join_date and the ed_mode selector).
|
||||
assert dropdown_html =~ ~s(name="jd_from")
|
||||
assert dropdown_html =~ ~s(name="jd_to")
|
||||
assert dropdown_html =~ ~s(name="ed_mode")
|
||||
end
|
||||
|
||||
test "exit_date custom mode reveals ed_from and ed_to inputs", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?ed_mode=custom")
|
||||
|
||||
view
|
||||
|> element("#member-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
dropdown_html =
|
||||
view
|
||||
|> element("#member-filter div[role='dialog']")
|
||||
|> render()
|
||||
|
||||
assert dropdown_html =~ ~s(name="ed_from")
|
||||
assert dropdown_html =~ ~s(name="ed_to")
|
||||
end
|
||||
|
||||
test "date inputs render via MvWeb.CoreComponents.input (no raw DaisyUI input markup)",
|
||||
%{conn: conn} do
|
||||
# DESIGN_GUIDELINES §1.1 mandates that LiveViews/HEEX use the project's
|
||||
# `<.input>` wrapper rather than emitting raw `<input>` tags carrying
|
||||
# DaisyUI component classes (e.g. `input input-sm input-bordered`)
|
||||
# directly in HEEX. `<.input>` is the project's single source of truth
|
||||
# for input styling; bypassing it splits styling across many call sites.
|
||||
#
|
||||
# The recognizable structural fingerprint of `<.input>` is a wrapping
|
||||
# `<fieldset class="mb-2 fieldset">` `<label>` chain immediately
|
||||
# preceding the `<input>`. The raw inline form has no such wrapper —
|
||||
# the input sits directly inside a sibling `<label>`/`<input>` flex row.
|
||||
# We assert that fingerprint on each of the date inputs.
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?ed_mode=custom")
|
||||
|
||||
view
|
||||
|> element("#member-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
dropdown_html =
|
||||
view
|
||||
|> element("#member-filter div[role='dialog']")
|
||||
|> render()
|
||||
|
||||
for name <- ["jd_from", "jd_to", "ed_from", "ed_to"] do
|
||||
# Match `<fieldset class="mb-2 fieldset">` followed (within a short
|
||||
# window of HTML) by an `<input>` carrying the expected `name`. The
|
||||
# window prevents the regex from spanning unrelated `mb-2` /
|
||||
# `fieldset` occurrences scattered across the dropdown. The wrapper
|
||||
# is the canonical fingerprint of `MvWeb.CoreComponents.input/1`
|
||||
# (see `lib/mv_web/components/core_components.ex` — every input
|
||||
# branch starts with `<fieldset class="mb-2 fieldset">`).
|
||||
assert Regex.match?(
|
||||
~r/<fieldset[^>]*class="mb-2 fieldset"[^>]*>\s*<label[^>]*>(?:\s*<span[^>]*>.*?<\/span>)?\s*<input[^>]*name="#{name}"/s,
|
||||
dropdown_html
|
||||
),
|
||||
"expected date input #{name} to be wrapped by MvWeb.CoreComponents.input " <>
|
||||
"(class=\"mb-2 fieldset\" fieldset wrapper), not a raw inline " <>
|
||||
"<input type=\"date\"> element"
|
||||
end
|
||||
end
|
||||
|
||||
test "exit_date defaults to :active_only in the rendered radio", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
view
|
||||
|> element("#member-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
dropdown_html =
|
||||
view
|
||||
|> element("#member-filter div[role='dialog']")
|
||||
|> render()
|
||||
|
||||
assert dropdown_html =~
|
||||
~r/name="ed_mode"[^>]*value="active_only"[^>]*checked|checked[^>]*name="ed_mode"[^>]*value="active_only"/
|
||||
end
|
||||
|
||||
test "Custom date fields section is non-scrollable with 5 or fewer fields (§3.4)", %{
|
||||
conn: conn
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
for i <- 1..5 do
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "DateField-#{i}-#{System.unique_integer([:positive])}",
|
||||
value_type: :date
|
||||
})
|
||||
|> Ash.create!(actor: system_actor)
|
||||
end
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
view
|
||||
|> element("#member-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
section_html = custom_date_section_html(view)
|
||||
|
||||
# With ≤ 5 fields the section must NOT carry the scrollable wrapper.
|
||||
refute section_html =~ "max-h-60"
|
||||
refute section_html =~ "overflow-y-auto"
|
||||
end
|
||||
|
||||
test "Custom date fields section becomes scrollable with more than 5 fields (§3.4)", %{
|
||||
conn: conn
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
for i <- 1..6 do
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "DateField-#{i}-#{System.unique_integer([:positive])}",
|
||||
value_type: :date
|
||||
})
|
||||
|> Ash.create!(actor: system_actor)
|
||||
end
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
view
|
||||
|> element("#member-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
section_html = custom_date_section_html(view)
|
||||
|
||||
# With more than 5 fields the section is wrapped in the scrollable container.
|
||||
assert section_html =~ "max-h-60"
|
||||
assert section_html =~ "overflow-y-auto"
|
||||
end
|
||||
|
||||
# Extract the HTML of the rendered "Custom date fields" section. Returns
|
||||
# "" if the section is not rendered. Used by the threshold tests to avoid
|
||||
# picking up scrollable classes from sibling sections.
|
||||
defp custom_date_section_html(view) do
|
||||
dropdown_html =
|
||||
view
|
||||
|> element("#member-filter div[role='dialog']")
|
||||
|> render()
|
||||
|
||||
label = gettext("Custom date fields")
|
||||
|
||||
case String.split(dropdown_html, label, parts: 2) do
|
||||
[_before, after_label] ->
|
||||
# Up to the next group header label, or the footer.
|
||||
after_label
|
||||
|> String.split(["text-xs font-semibold opacity-70 mb-2 uppercase"], parts: 2)
|
||||
|> List.first()
|
||||
|
||||
_ ->
|
||||
""
|
||||
end
|
||||
end
|
||||
|
||||
test "Custom date fields section appears only when date custom fields exist", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view_no_field, _} = live(conn, "/members")
|
||||
|
||||
view_no_field
|
||||
|> element("#member-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
dropdown_html =
|
||||
view_no_field
|
||||
|> element("#member-filter div[role='dialog']")
|
||||
|> render()
|
||||
|
||||
refute dropdown_html =~ gettext("Custom date fields")
|
||||
|
||||
# Add a date-typed custom field and re-load: the section appears.
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Birthday-#{System.unique_integer([:positive])}",
|
||||
value_type: :date
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
|
||||
{:ok, view, _} = live(conn, "/members")
|
||||
|
||||
view
|
||||
|> element("#member-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
dropdown_html =
|
||||
view
|
||||
|> element("#member-filter div[role='dialog']")
|
||||
|> render()
|
||||
|
||||
assert dropdown_html =~ gettext("Custom date fields")
|
||||
assert dropdown_html =~ field.name
|
||||
assert dropdown_html =~ "cdf_#{field.id}_from"
|
||||
assert dropdown_html =~ "cdf_#{field.id}_to"
|
||||
end
|
||||
|
||||
test "update_filters event dispatches a date_filters_changed patch with the new jd_from", %{
|
||||
conn: conn
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
view
|
||||
|> element("#member-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
view
|
||||
|> form("#member-filter form", %{
|
||||
"jd_from" => "2024-01-15",
|
||||
"payment_filter" => "all"
|
||||
})
|
||||
|> render_change()
|
||||
|
||||
# Parent LiveView receives {:date_filters_changed, ...} and patches the URL.
|
||||
path = assert_patch(view)
|
||||
assert path =~ "jd_from=2024-01-15"
|
||||
end
|
||||
|
||||
test "selecting ed_mode=all updates the URL and reveals former members", %{conn: conn} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
today = Date.utc_today()
|
||||
unique_name = "Zarquon-#{System.unique_integer([:positive])}"
|
||||
|
||||
{:ok, former} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: unique_name,
|
||||
last_name: "Exited",
|
||||
email: "ex-#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: Date.add(today, -1000),
|
||||
exit_date: Date.add(today, -30)
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, html} = live(conn, "/members")
|
||||
|
||||
# Fresh load hides the former member.
|
||||
refute html =~ former.first_name
|
||||
|
||||
view
|
||||
|> element("#member-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
view
|
||||
|> form("#member-filter form", %{
|
||||
"ed_mode" => "all",
|
||||
"payment_filter" => "all"
|
||||
})
|
||||
|> render_change()
|
||||
|
||||
path = assert_patch(view)
|
||||
assert path =~ "ed_mode=all"
|
||||
|
||||
# Now Eve appears in the rendered list.
|
||||
assert render(view) =~ former.first_name
|
||||
end
|
||||
|
||||
test "dropdown shows scrollbar when many boolean custom fields exist", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@ defmodule MvWeb.MemberLive.Index.DateFilterPropertyTest do
|
|||
|
||||
* `to_params/1` ∘ `from_params/2` must be the identity for all valid
|
||||
built-in date filter states (§2.3).
|
||||
* `apply_in_memory/3` matches the inclusive range predicate
|
||||
`(from == nil or value >= from) and (to == nil or value <= to)`
|
||||
for any custom date field value and bound pair (§2.4).
|
||||
|
||||
Custom date field entries are not part of this property because
|
||||
Custom date field round-trip is not part of the URL codec property because
|
||||
`from_params/2` needs the caller-supplied `date_custom_fields` list to
|
||||
validate UUIDs; the standalone property for the in-memory predicate (§2.4)
|
||||
is covered in S8 after the predicate exists.
|
||||
validate UUIDs; that interaction is covered by example tests in
|
||||
`MvWeb.MemberLive.Index.DateFilterTest`.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue