Rework member overview #553
17 changed files with 375 additions and 875 deletions
|
|
@ -145,7 +145,7 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
|> Ash.Query.new()
|
|> Ash.Query.new()
|
||||||
|> Ash.Query.select(select_fields)
|
|> Ash.Query.select(select_fields)
|
||||||
|> load_custom_field_values_query(custom_field_ids_union)
|
|> load_custom_field_values_query(custom_field_ids_union)
|
||||||
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
|> maybe_load_cycles(need_cycles)
|
||||||
|> maybe_load_groups(need_groups)
|
|> maybe_load_groups(need_groups)
|
||||||
|> maybe_load_membership_fee_type(need_membership_fee_type)
|
|> maybe_load_membership_fee_type(need_membership_fee_type)
|
||||||
|
|
||||||
|
|
@ -359,10 +359,10 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
|
|
||||||
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
|
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
|
||||||
|
|
||||||
defp maybe_load_cycles(query, false, _show_current), do: query
|
defp maybe_load_cycles(query, false), do: query
|
||||||
|
|
||||||
defp maybe_load_cycles(query, true, show_current) do
|
defp maybe_load_cycles(query, true) do
|
||||||
MembershipFeeStatus.load_cycles_for_members(query, show_current)
|
MembershipFeeStatus.load_cycles_for_members(query)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_load_groups(query, false), do: query
|
defp maybe_load_groups(query, false), do: query
|
||||||
|
|
|
||||||
|
|
@ -143,12 +143,18 @@ defmodule MvWeb.CoreComponents do
|
||||||
|
|
||||||
attr :size, :string, values: ~w(sm md lg), default: "md"
|
attr :size, :string, values: ~w(sm md lg), default: "md"
|
||||||
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
|
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
|
||||||
|
|
||||||
|
attr :active, :boolean,
|
||||||
|
default: false,
|
||||||
|
doc: "Renders the button in its active/pressed (toggled-on) state"
|
||||||
|
|
||||||
slot :inner_block, required: true
|
slot :inner_block, required: true
|
||||||
|
|
||||||
def button(assigns) do
|
def button(assigns) do
|
||||||
rest = assigns.rest
|
rest = assigns.rest
|
||||||
variant = assigns[:variant] || "primary"
|
variant = assigns[:variant] || "primary"
|
||||||
size = assigns[:size] || "md"
|
size = assigns[:size] || "md"
|
||||||
|
active_class = if assigns[:active], do: "btn-active", else: ""
|
||||||
|
|
||||||
variant_classes = %{
|
variant_classes = %{
|
||||||
"primary" => "btn-primary",
|
"primary" => "btn-primary",
|
||||||
|
|
@ -169,7 +175,9 @@ defmodule MvWeb.CoreComponents do
|
||||||
|
|
||||||
base_class = Map.fetch!(variant_classes, variant)
|
base_class = Map.fetch!(variant_classes, variant)
|
||||||
size_class = size_classes[size]
|
size_class = size_classes[size]
|
||||||
btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ")
|
|
||||||
|
btn_class =
|
||||||
|
[base_class, size_class, active_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ")
|
||||||
|
|
||||||
assigns =
|
assigns =
|
||||||
assigns
|
assigns
|
||||||
|
|
@ -214,6 +222,27 @@ defmodule MvWeb.CoreComponents do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a loading spinner for inline "busy" states.
|
||||||
|
|
||||||
|
Wraps the DaisyUI `loading` classes so views never spell them out. Mark it
|
||||||
|
`aria-hidden="true"` when an adjacent text label already conveys the busy state.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.spinner />
|
||||||
|
<.spinner size="lg" aria-hidden="true" />
|
||||||
|
"""
|
||||||
|
attr :size, :string, values: ~w(xs sm md lg), default: "sm"
|
||||||
|
attr :class, :any, default: nil, doc: "Additional layout classes"
|
||||||
|
attr :rest, :global
|
||||||
|
|
||||||
|
def spinner(assigns) do
|
||||||
|
~H"""
|
||||||
|
<span class={["loading loading-spinner", "loading-#{@size}", @class]} {@rest}></span>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders a non-interactive badge with WCAG-compliant contrast.
|
Renders a non-interactive badge with WCAG-compliant contrast.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -294,7 +294,7 @@ defmodule MvWeb.MemberExportController do
|
||||||
|> Ash.Query.new()
|
|> Ash.Query.new()
|
||||||
|> Ash.Query.select(select_fields)
|
|> Ash.Query.select(select_fields)
|
||||||
|> load_custom_field_values_query(parsed.custom_field_ids_union)
|
|> load_custom_field_values_query(parsed.custom_field_ids_union)
|
||||||
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
|> maybe_load_cycles(need_cycles)
|
||||||
|> maybe_load_groups(need_groups)
|
|> maybe_load_groups(need_groups)
|
||||||
|> maybe_load_membership_fee_type(need_membership_fee_type)
|
|> maybe_load_membership_fee_type(need_membership_fee_type)
|
||||||
|
|
||||||
|
|
@ -338,10 +338,10 @@ defmodule MvWeb.MemberExportController do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_load_cycles(query, false, _show_current), do: query
|
defp maybe_load_cycles(query, false), do: query
|
||||||
|
|
||||||
defp maybe_load_cycles(query, true, show_current) do
|
defp maybe_load_cycles(query, true) do
|
||||||
MembershipFeeStatus.load_cycles_for_members(query, show_current)
|
MembershipFeeStatus.load_cycles_for_members(query)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_load_groups(query, false), do: query
|
defp maybe_load_groups(query, false), do: query
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
alias MvWeb.Helpers.DateFormatter
|
alias MvWeb.Helpers.DateFormatter
|
||||||
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
|
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
|
||||||
alias MvWeb.MemberLive.Index.DateFilter
|
alias MvWeb.MemberLive.Index.DateFilter
|
||||||
|
alias MvWeb.MemberLive.Index.ExportPayload
|
||||||
alias MvWeb.MemberLive.Index.FieldSelection
|
alias MvWeb.MemberLive.Index.FieldSelection
|
||||||
alias MvWeb.MemberLive.Index.FieldVisibility
|
alias MvWeb.MemberLive.Index.FieldVisibility
|
||||||
alias MvWeb.MemberLive.Index.FilterParams
|
alias MvWeb.MemberLive.Index.FilterParams
|
||||||
|
|
@ -377,8 +378,8 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
query_params =
|
query_params =
|
||||||
build_query_params(
|
build_query_params(
|
||||||
opts_for_query_params(socket, %{
|
opts_for_query_params(socket, %{
|
||||||
sort_field: export_sort_field(socket.assigns.sort_field),
|
sort_field: ExportPayload.sort_field(socket.assigns.sort_field),
|
||||||
sort_order: export_sort_order(socket.assigns.sort_order)
|
sort_order: ExportPayload.sort_order(socket.assigns.sort_order)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|> maybe_add_field_selection(
|
|> maybe_add_field_selection(
|
||||||
|
|
@ -553,53 +554,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
{:noreply, push_reload(socket, new_path)}
|
{:noreply, push_reload(socket, new_path)}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Backward compatibility: tuple form delegates to map form
|
|
||||||
def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
|
|
||||||
handle_info(
|
|
||||||
{:reset_all_filters,
|
|
||||||
%{
|
|
||||||
cycle_status_filter: cycle_status_filter,
|
|
||||||
boolean_filters: boolean_filters,
|
|
||||||
group_filters: %{},
|
|
||||||
fee_type_filters: %{}
|
|
||||||
}},
|
|
||||||
socket
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_info(
|
|
||||||
{:reset_all_filters, cycle_status_filter, boolean_filters, group_filters},
|
|
||||||
socket
|
|
||||||
) do
|
|
||||||
handle_info(
|
|
||||||
{:reset_all_filters,
|
|
||||||
%{
|
|
||||||
cycle_status_filter: cycle_status_filter,
|
|
||||||
boolean_filters: boolean_filters,
|
|
||||||
group_filters: group_filters,
|
|
||||||
fee_type_filters: %{}
|
|
||||||
}},
|
|
||||||
socket
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_info(
|
|
||||||
{:reset_all_filters, cycle_status_filter, boolean_filters, group_filters,
|
|
||||||
fee_type_filters},
|
|
||||||
socket
|
|
||||||
) do
|
|
||||||
handle_info(
|
|
||||||
{:reset_all_filters,
|
|
||||||
%{
|
|
||||||
cycle_status_filter: cycle_status_filter,
|
|
||||||
boolean_filters: boolean_filters,
|
|
||||||
group_filters: group_filters,
|
|
||||||
fee_type_filters: fee_type_filters
|
|
||||||
}},
|
|
||||||
socket
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_info({:reset_all_filters, %{} = opts}, socket) do
|
def handle_info({:reset_all_filters, %{} = opts}, socket) do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|
|
@ -1348,7 +1302,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> OverviewQuery.build()
|
|> OverviewQuery.build()
|
||||||
|> Ash.Query.select(@overview_fields)
|
|> Ash.Query.select(@overview_fields)
|
||||||
|> load_custom_field_values(compute_ids_to_load(socket))
|
|> load_custom_field_values(compute_ids_to_load(socket))
|
||||||
|> MembershipFeeStatus.load_cycles_for_members(socket.assigns.show_current_cycle)
|
|> MembershipFeeStatus.load_cycles_for_members()
|
||||||
|> Ash.Query.load(groups: [:id, :name, :slug])
|
|> Ash.Query.load(groups: [:id, :name, :slug])
|
||||||
|> maybe_load_fee_type(socket)
|
|> maybe_load_fee_type(socket)
|
||||||
end
|
end
|
||||||
|
|
@ -1490,8 +1444,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
String.starts_with?(field, @custom_field_prefix)
|
String.starts_with?(field, @custom_field_prefix)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp custom_field_sort?(_), do: false
|
|
||||||
|
|
||||||
defp extract_custom_field_ids(visible_custom_fields) do
|
defp extract_custom_field_ids(visible_custom_fields) do
|
||||||
Enum.map(visible_custom_fields, fn field_string ->
|
Enum.map(visible_custom_fields, fn field_string ->
|
||||||
case String.split(field_string, @custom_field_prefix) do
|
case String.split(field_string, @custom_field_prefix) do
|
||||||
|
|
@ -1781,13 +1733,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
CustomFieldValueLookup.find_by_field(member, custom_field)
|
CustomFieldValueLookup.find_by_field(member, custom_field)
|
||||||
end
|
end
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
defp extract_boolean_value(%Ash.Union{value: value, type: :boolean}),
|
defp extract_boolean_value(%Ash.Union{value: value, type: :boolean}),
|
||||||
do: extract_boolean_value(value)
|
do: extract_boolean_value(value)
|
||||||
|
|
||||||
|
|
@ -1843,14 +1788,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def format_selected_member_emails(members, selected_members) do
|
|
||||||
members
|
|
||||||
|> Enum.filter(fn member ->
|
|
||||||
MapSet.member?(selected_members, member.id) && member.email && member.email != ""
|
|
||||||
end)
|
|
||||||
|> Enum.map(&format_member_email/1)
|
|
||||||
end
|
|
||||||
|
|
||||||
def checkbox_column_click(member), do: JS.push("select_member", value: %{id: member.id})
|
def checkbox_column_click(member), do: JS.push("select_member", value: %{id: member.id})
|
||||||
|
|
||||||
def format_member_email(member) do
|
def format_member_email(member) do
|
||||||
|
|
@ -1997,149 +1934,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp assign_export_payload(socket) do
|
defp assign_export_payload(socket) do
|
||||||
payload = build_export_payload(socket)
|
payload = ExportPayload.build(socket.assigns)
|
||||||
assign(socket, :export_payload_json, Jason.encode!(payload))
|
assign(socket, :export_payload_json, Jason.encode!(payload))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_export_payload(socket) do
|
|
||||||
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
|
||||||
|
|
||||||
member_fields_db = socket.assigns[:member_fields_visible_db] || []
|
|
||||||
member_fields_computed = socket.assigns[:member_fields_visible_computed] || []
|
|
||||||
|
|
||||||
# Order DB member fields exactly like the table/constants
|
|
||||||
ordered_member_fields_db =
|
|
||||||
Mv.Constants.member_fields()
|
|
||||||
|> Enum.filter(&(&1 in member_fields_db))
|
|
||||||
|
|
||||||
# Order computed fields in canonical order
|
|
||||||
ordered_computed_fields =
|
|
||||||
FieldVisibility.computed_member_fields()
|
|
||||||
|> Enum.filter(&(&1 in member_fields_computed))
|
|
||||||
|
|
||||||
member_fields_with_groups =
|
|
||||||
build_export_member_fields_list(
|
|
||||||
ordered_member_fields_db,
|
|
||||||
socket.assigns[:member_fields_visible]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Order custom fields like the table (same as dynamic_cols / all_custom_fields order)
|
|
||||||
ordered_custom_field_ids =
|
|
||||||
socket.assigns.all_custom_fields
|
|
||||||
|> Enum.map(&to_string(&1.id))
|
|
||||||
|> Enum.filter(&(&1 in visible_custom_field_ids))
|
|
||||||
|
|
||||||
%{
|
|
||||||
selected_ids: socket.assigns.selected_members |> MapSet.to_list(),
|
|
||||||
member_fields:
|
|
||||||
Enum.map(member_fields_with_groups, fn
|
|
||||||
f when is_atom(f) -> Atom.to_string(f)
|
|
||||||
f when is_binary(f) -> f
|
|
||||||
end),
|
|
||||||
computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1),
|
|
||||||
custom_field_ids: ordered_custom_field_ids,
|
|
||||||
column_order:
|
|
||||||
export_column_order(
|
|
||||||
ordered_member_fields_db,
|
|
||||||
ordered_computed_fields,
|
|
||||||
ordered_custom_field_ids,
|
|
||||||
:membership_fee_type in socket.assigns[:member_fields_visible],
|
|
||||||
:groups in socket.assigns[:member_fields_visible]
|
|
||||||
),
|
|
||||||
query: socket.assigns[:query] || nil,
|
|
||||||
sort_field: export_sort_field(socket.assigns[:sort_field]),
|
|
||||||
sort_order: export_sort_order(socket.assigns[:sort_order]),
|
|
||||||
show_current_cycle: socket.assigns[:show_current_cycle] || false,
|
|
||||||
cycle_status_filter: export_cycle_status_filter(socket.assigns[:cycle_status_filter]),
|
|
||||||
boolean_filters: socket.assigns[:boolean_custom_field_filters] || %{}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp expand_db_string_for_export(f, membership_fee_type_visible, computed_strings) do
|
|
||||||
if f == "membership_fee_start_date" do
|
|
||||||
extra =
|
|
||||||
if(membership_fee_type_visible, do: ["membership_fee_type"], else: []) ++
|
|
||||||
if "membership_fee_status" in computed_strings, do: ["membership_fee_status"], else: []
|
|
||||||
|
|
||||||
[f] ++ extra
|
|
||||||
else
|
|
||||||
[f]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_export_member_fields_list(ordered_db, member_fields_visible) do
|
|
||||||
with_extras =
|
|
||||||
Enum.flat_map(ordered_db, fn f ->
|
|
||||||
if f == :membership_fee_start_date and
|
|
||||||
:membership_fee_type in (member_fields_visible || []) do
|
|
||||||
[f, :membership_fee_type]
|
|
||||||
else
|
|
||||||
[f]
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
# If fee type is visible but start_date was not in the list, append it
|
|
||||||
with_extras =
|
|
||||||
if :membership_fee_type in (member_fields_visible || []) and
|
|
||||||
:membership_fee_type not in with_extras do
|
|
||||||
with_extras ++ [:membership_fee_type]
|
|
||||||
else
|
|
||||||
with_extras
|
|
||||||
end
|
|
||||||
|
|
||||||
if :groups in (member_fields_visible || []), do: with_extras ++ [:groups], else: with_extras
|
|
||||||
end
|
|
||||||
|
|
||||||
defp export_cycle_status_filter(nil), do: nil
|
|
||||||
defp export_cycle_status_filter(:paid), do: "paid"
|
|
||||||
defp export_cycle_status_filter(:unpaid), do: "unpaid"
|
|
||||||
defp export_cycle_status_filter(_), do: nil
|
|
||||||
|
|
||||||
defp export_sort_field(nil), do: nil
|
|
||||||
defp export_sort_field(f) when is_atom(f), do: Atom.to_string(f)
|
|
||||||
defp export_sort_field(f) when is_binary(f), do: f
|
|
||||||
|
|
||||||
defp export_sort_order(nil), do: nil
|
|
||||||
defp export_sort_order(:asc), do: "asc"
|
|
||||||
defp export_sort_order(:desc), do: "desc"
|
|
||||||
defp export_sort_order(o) when is_binary(o), do: o
|
|
||||||
# Build a single ordered list that matches the table order:
|
|
||||||
# - DB fields in Mv.Constants.member_fields() order (already pre-filtered as ordered_member_fields_db)
|
|
||||||
# - membership_fee_type and membership_fee_status inserted after membership_fee_start_date when visible
|
|
||||||
# - groups appended before custom fields when visible
|
|
||||||
# - custom fields appended in the same order as table (already ordered_custom_field_ids)
|
|
||||||
defp export_column_order(
|
|
||||||
ordered_member_fields_db,
|
|
||||||
ordered_computed_fields,
|
|
||||||
ordered_custom_field_ids,
|
|
||||||
membership_fee_type_visible,
|
|
||||||
groups_visible
|
|
||||||
) do
|
|
||||||
db_strings = Enum.map(ordered_member_fields_db, &Atom.to_string/1)
|
|
||||||
computed_strings = Enum.map(ordered_computed_fields, &Atom.to_string/1)
|
|
||||||
|
|
||||||
# Place membership_fee_type and membership_fee_status after membership_fee_start_date when present
|
|
||||||
db_with_extras =
|
|
||||||
Enum.flat_map(
|
|
||||||
db_strings,
|
|
||||||
&expand_db_string_for_export(&1, membership_fee_type_visible, computed_strings)
|
|
||||||
)
|
|
||||||
|
|
||||||
# If fee type is visible but start_date was not in the list, append it before computed/groups
|
|
||||||
db_with_extras =
|
|
||||||
if membership_fee_type_visible and "membership_fee_type" not in db_with_extras do
|
|
||||||
db_with_extras ++ ["membership_fee_type"]
|
|
||||||
else
|
|
||||||
db_with_extras
|
|
||||||
end
|
|
||||||
|
|
||||||
# Any remaining computed fields not inserted above (future-proof)
|
|
||||||
remaining_computed =
|
|
||||||
computed_strings
|
|
||||||
|> Enum.reject(&(&1 in db_with_extras))
|
|
||||||
|
|
||||||
result = db_with_extras ++ remaining_computed
|
|
||||||
result = if groups_visible, do: result ++ ["groups"], else: result
|
|
||||||
result ++ ordered_custom_field_ids
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
28
lib/mv_web/live/member_live/index/cookie.ex
Normal file
28
lib/mv_web/live/member_live/index/cookie.ex
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
defmodule MvWeb.MemberLive.Index.Cookie do
|
||||||
|
@moduledoc """
|
||||||
|
Shared cookie-header parsing for the member-overview persistence chain.
|
||||||
|
|
||||||
|
Both `MvWeb.MemberLive.Index.ViewSettings` and
|
||||||
|
`MvWeb.MemberLive.Index.FieldSelection` read their persisted value from the
|
||||||
|
raw request `Cookie:` header on the disconnected mount, so the parsing lives
|
||||||
|
here once.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Parses a raw `Cookie:` request-header string into a `%{name => value}` map,
|
||||||
|
URL-decoding each value. A valueless entry (`"key"`) maps to `""`; malformed
|
||||||
|
segments are skipped.
|
||||||
|
"""
|
||||||
|
@spec parse_header(String.t()) :: %{String.t() => String.t()}
|
||||||
|
def parse_header(cookie_header) when is_binary(cookie_header) do
|
||||||
|
cookie_header
|
||||||
|
|> String.split(";")
|
||||||
|
|> Enum.map(&String.trim/1)
|
||||||
|
|> Enum.map(&String.split(&1, "=", parts: 2))
|
||||||
|
|> Enum.reduce(%{}, fn
|
||||||
|
[key, value], acc -> Map.put(acc, key, URI.decode(value))
|
||||||
|
[key], acc -> Map.put(acc, key, "")
|
||||||
|
_, acc -> acc
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
163
lib/mv_web/live/member_live/index/export_payload.ex
Normal file
163
lib/mv_web/live/member_live/index/export_payload.ex
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
defmodule MvWeb.MemberLive.Index.ExportPayload do
|
||||||
|
@moduledoc """
|
||||||
|
Builds the member-overview export descriptor from the LiveView assigns.
|
||||||
|
|
||||||
|
The overview hands this payload (as JSON) to the export flow so a CSV export
|
||||||
|
reproduces exactly the columns, order, filters, and sort the user currently
|
||||||
|
sees. It is pure: it reads the overview assigns and returns a plain map; the
|
||||||
|
LiveView owns encoding and assigning it.
|
||||||
|
|
||||||
|
Column order mirrors the rendered table:
|
||||||
|
|
||||||
|
* DB member fields in `Mv.Constants.member_fields/0` order;
|
||||||
|
* `membership_fee_type` / `membership_fee_status` inserted right after
|
||||||
|
`membership_fee_start_date` when visible;
|
||||||
|
* `groups` appended before the custom fields when visible;
|
||||||
|
* custom fields in the table's custom-field order.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias MvWeb.MemberLive.Index.FieldVisibility
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Builds the export descriptor map from the overview assigns.
|
||||||
|
"""
|
||||||
|
@spec build(map()) :: map()
|
||||||
|
def build(assigns) do
|
||||||
|
visible_custom_field_ids = assigns[:visible_custom_field_ids] || []
|
||||||
|
|
||||||
|
member_fields_db = assigns[:member_fields_visible_db] || []
|
||||||
|
member_fields_computed = assigns[:member_fields_visible_computed] || []
|
||||||
|
member_fields_visible = assigns[:member_fields_visible] || []
|
||||||
|
|
||||||
|
# Order DB member fields exactly like the table/constants.
|
||||||
|
ordered_member_fields_db =
|
||||||
|
Mv.Constants.member_fields()
|
||||||
|
|> Enum.filter(&(&1 in member_fields_db))
|
||||||
|
|
||||||
|
# Order computed fields in canonical order.
|
||||||
|
ordered_computed_fields =
|
||||||
|
FieldVisibility.computed_member_fields()
|
||||||
|
|> Enum.filter(&(&1 in member_fields_computed))
|
||||||
|
|
||||||
|
member_fields_with_groups =
|
||||||
|
build_member_fields_list(ordered_member_fields_db, member_fields_visible)
|
||||||
|
|
||||||
|
# Order custom fields like the table (same as dynamic_cols / all_custom_fields order).
|
||||||
|
ordered_custom_field_ids =
|
||||||
|
assigns.all_custom_fields
|
||||||
|
|> Enum.map(&to_string(&1.id))
|
||||||
|
|> Enum.filter(&(&1 in visible_custom_field_ids))
|
||||||
|
|
||||||
|
%{
|
||||||
|
selected_ids: assigns.selected_members |> MapSet.to_list(),
|
||||||
|
member_fields:
|
||||||
|
Enum.map(member_fields_with_groups, fn
|
||||||
|
f when is_atom(f) -> Atom.to_string(f)
|
||||||
|
f when is_binary(f) -> f
|
||||||
|
end),
|
||||||
|
computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1),
|
||||||
|
custom_field_ids: ordered_custom_field_ids,
|
||||||
|
column_order:
|
||||||
|
column_order(
|
||||||
|
ordered_member_fields_db,
|
||||||
|
ordered_computed_fields,
|
||||||
|
ordered_custom_field_ids,
|
||||||
|
:membership_fee_type in member_fields_visible,
|
||||||
|
:groups in member_fields_visible
|
||||||
|
),
|
||||||
|
query: assigns[:query] || nil,
|
||||||
|
sort_field: sort_field(assigns[:sort_field]),
|
||||||
|
sort_order: sort_order(assigns[:sort_order]),
|
||||||
|
show_current_cycle: assigns[:show_current_cycle] || false,
|
||||||
|
cycle_status_filter: cycle_status_filter(assigns[:cycle_status_filter]),
|
||||||
|
boolean_filters: assigns[:boolean_custom_field_filters] || %{}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Serializes a sort field (atom or string) to its export string, or nil."
|
||||||
|
@spec sort_field(atom() | String.t() | nil) :: String.t() | nil
|
||||||
|
def sort_field(nil), do: nil
|
||||||
|
def sort_field(f) when is_atom(f), do: Atom.to_string(f)
|
||||||
|
def sort_field(f) when is_binary(f), do: f
|
||||||
|
|
||||||
|
@doc "Serializes a sort order (atom or string) to its export string, or nil."
|
||||||
|
@spec sort_order(atom() | String.t() | nil) :: String.t() | nil
|
||||||
|
def sort_order(nil), do: nil
|
||||||
|
def sort_order(:asc), do: "asc"
|
||||||
|
def sort_order(:desc), do: "desc"
|
||||||
|
def sort_order(o) when is_binary(o), do: o
|
||||||
|
|
||||||
|
defp cycle_status_filter(nil), do: nil
|
||||||
|
defp cycle_status_filter(:paid), do: "paid"
|
||||||
|
defp cycle_status_filter(:unpaid), do: "unpaid"
|
||||||
|
defp cycle_status_filter(_), do: nil
|
||||||
|
|
||||||
|
defp build_member_fields_list(ordered_db, member_fields_visible) do
|
||||||
|
member_fields_visible = member_fields_visible || []
|
||||||
|
|
||||||
|
with_extras =
|
||||||
|
Enum.flat_map(ordered_db, fn f ->
|
||||||
|
if f == :membership_fee_start_date and :membership_fee_type in member_fields_visible do
|
||||||
|
[f, :membership_fee_type]
|
||||||
|
else
|
||||||
|
[f]
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
# If fee type is visible but start_date was not in the list, append it.
|
||||||
|
with_extras =
|
||||||
|
if :membership_fee_type in member_fields_visible and
|
||||||
|
:membership_fee_type not in with_extras do
|
||||||
|
with_extras ++ [:membership_fee_type]
|
||||||
|
else
|
||||||
|
with_extras
|
||||||
|
end
|
||||||
|
|
||||||
|
if :groups in member_fields_visible, do: with_extras ++ [:groups], else: with_extras
|
||||||
|
end
|
||||||
|
|
||||||
|
defp expand_db_string(f, membership_fee_type_visible, computed_strings) do
|
||||||
|
if f == "membership_fee_start_date" do
|
||||||
|
extra =
|
||||||
|
if(membership_fee_type_visible, do: ["membership_fee_type"], else: []) ++
|
||||||
|
if "membership_fee_status" in computed_strings, do: ["membership_fee_status"], else: []
|
||||||
|
|
||||||
|
[f] ++ extra
|
||||||
|
else
|
||||||
|
[f]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp column_order(
|
||||||
|
ordered_member_fields_db,
|
||||||
|
ordered_computed_fields,
|
||||||
|
ordered_custom_field_ids,
|
||||||
|
membership_fee_type_visible,
|
||||||
|
groups_visible
|
||||||
|
) do
|
||||||
|
db_strings = Enum.map(ordered_member_fields_db, &Atom.to_string/1)
|
||||||
|
computed_strings = Enum.map(ordered_computed_fields, &Atom.to_string/1)
|
||||||
|
|
||||||
|
# Place membership_fee_type and membership_fee_status after membership_fee_start_date when present.
|
||||||
|
db_with_extras =
|
||||||
|
Enum.flat_map(
|
||||||
|
db_strings,
|
||||||
|
&expand_db_string(&1, membership_fee_type_visible, computed_strings)
|
||||||
|
)
|
||||||
|
|
||||||
|
# If fee type is visible but start_date was not in the list, append it before computed/groups.
|
||||||
|
db_with_extras =
|
||||||
|
if membership_fee_type_visible and "membership_fee_type" not in db_with_extras do
|
||||||
|
db_with_extras ++ ["membership_fee_type"]
|
||||||
|
else
|
||||||
|
db_with_extras
|
||||||
|
end
|
||||||
|
|
||||||
|
# Any remaining computed fields not inserted above (future-proof).
|
||||||
|
remaining_computed = Enum.reject(computed_strings, &(&1 in db_with_extras))
|
||||||
|
|
||||||
|
result = db_with_extras ++ remaining_computed
|
||||||
|
result = if groups_visible, do: result ++ ["groups"], else: result
|
||||||
|
result ++ ordered_custom_field_ids
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -29,6 +29,8 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
|
||||||
Comma-separated list: `?fields=first_name,email,custom_field_abc-123`
|
Comma-separated list: `?fields=first_name,email,custom_field_abc-123`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
alias MvWeb.MemberLive.Index.Cookie
|
||||||
|
|
||||||
@cookie_name "member_field_selection"
|
@cookie_name "member_field_selection"
|
||||||
@cookie_max_age 365 * 24 * 60 * 60
|
@cookie_max_age 365 * 24 * 60 * 60
|
||||||
@session_key "member_field_selection"
|
@session_key "member_field_selection"
|
||||||
|
|
@ -80,7 +82,7 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
|
||||||
%{}
|
%{}
|
||||||
|
|
||||||
[cookie_header | _rest] ->
|
[cookie_header | _rest] ->
|
||||||
cookies = parse_cookie_header(cookie_header)
|
cookies = Cookie.parse_header(cookie_header)
|
||||||
|
|
||||||
case Map.get(cookies, @cookie_name) do
|
case Map.get(cookies, @cookie_name) do
|
||||||
nil -> %{}
|
nil -> %{}
|
||||||
|
|
@ -90,19 +92,6 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Parses cookie header string into a map
|
|
||||||
defp parse_cookie_header(cookie_header) when is_binary(cookie_header) do
|
|
||||||
cookie_header
|
|
||||||
|> String.split(";")
|
|
||||||
|> Enum.map(&String.trim/1)
|
|
||||||
|> Enum.map(&String.split(&1, "=", parts: 2))
|
|
||||||
|> Enum.reduce(%{}, fn
|
|
||||||
[key, value], acc -> Map.put(acc, key, URI.decode(value))
|
|
||||||
[key], acc -> Map.put(acc, key, "")
|
|
||||||
_, acc -> acc
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Saves field selection to cookie.
|
Saves field selection to cookie.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,30 +60,12 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
:join_date
|
:join_date
|
||||||
])
|
])
|
||||||
|
|
||||||
@doc """
|
# The curated default-visible column set for the given view settings. In
|
||||||
The curated default-visible columns (member-field atoms) for a first visit.
|
# compact mode the composite `:name` / `:address` columns are default; when a
|
||||||
"""
|
# composite is switched off, its constituent columns take over as the defaults
|
||||||
@spec default_visible_fields() :: [atom()]
|
# instead (§7b). While the Member composite is compact, `include_email` decides
|
||||||
def default_visible_fields, do: MapSet.to_list(@default_visible_fields)
|
# where the email lives: folded into the Member cell (`true`, no separate
|
||||||
|
# column) or as a default-visible `:email` column (`false`).
|
||||||
@doc """
|
|
||||||
The curated default-visible columns for the given view settings.
|
|
||||||
|
|
||||||
In compact mode the composite `:name` / `:address` columns are visible by
|
|
||||||
default; when a composite is switched off, its constituent columns take over
|
|
||||||
as the defaults instead (§7b).
|
|
||||||
|
|
||||||
While the Member composite is compact, `include_email` decides where the email
|
|
||||||
lives: folded into the Member cell (`true`, no separate column), or as a
|
|
||||||
default-visible `:email` column (`false`).
|
|
||||||
"""
|
|
||||||
@spec default_visible_fields(boolean(), boolean(), boolean()) :: [atom()]
|
|
||||||
def default_visible_fields(compact_member, include_email, compact_address) do
|
|
||||||
compact_member
|
|
||||||
|> default_visible_set(include_email, compact_address)
|
|
||||||
|> MapSet.to_list()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp default_visible_set(compact_member, include_email, compact_address) do
|
defp default_visible_set(compact_member, include_email, compact_address) do
|
||||||
@default_visible_fields
|
@default_visible_fields
|
||||||
|> apply_name_default(compact_member, include_email)
|
|> apply_name_default(compact_member, include_email)
|
||||||
|
|
@ -205,48 +187,12 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets all available fields for selection.
|
Builds field selection from URL, scoped to the columns offered for the given
|
||||||
|
view settings (`:compact_member` / `:member_include_email` / `:compact_address`
|
||||||
|
in `opts`): fields in `url_selection` are visible, all others false.
|
||||||
|
|
||||||
Returns a list of field identifiers:
|
Use when `?fields=...` is in the URL so column visibility is not merged with
|
||||||
- Member fields as atoms (e.g., `:first_name`, `:email`)
|
global settings.
|
||||||
- Custom fields as strings (e.g., `"custom_field_abc-123"`)
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `custom_fields` - List of CustomField resources that are available
|
|
||||||
|
|
||||||
## Returns
|
|
||||||
|
|
||||||
List of field identifiers (atoms and strings)
|
|
||||||
"""
|
|
||||||
@spec get_all_available_fields([struct()]) :: [atom() | String.t()]
|
|
||||||
def get_all_available_fields(custom_fields) do
|
|
||||||
member_fields =
|
|
||||||
overview_member_fields()
|
|
||||||
|> Enum.reject(fn field -> field == @export_only_alias end)
|
|
||||||
|
|
||||||
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
|
|
||||||
|
|
||||||
member_fields ++ custom_field_names
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Builds field selection from URL only: fields in `url_selection` are visible, all others false.
|
|
||||||
Use when `?fields=...` is in the URL so column visibility is not merged with global settings.
|
|
||||||
"""
|
|
||||||
@spec selection_from_url_only(%{String.t() => boolean()}, [struct()]) :: %{
|
|
||||||
String.t() => boolean()
|
|
||||||
}
|
|
||||||
def selection_from_url_only(url_selection, custom_fields) when is_map(url_selection) do
|
|
||||||
do_selection_from_url_only(url_selection, get_all_available_fields(custom_fields))
|
|
||||||
end
|
|
||||||
|
|
||||||
def selection_from_url_only(_, _), do: %{}
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Like `selection_from_url_only/2`, but scoped to the columns offered for the
|
|
||||||
given view settings (`:compact_member` / `:member_include_email` /
|
|
||||||
`:compact_address` in `opts`).
|
|
||||||
"""
|
"""
|
||||||
@spec selection_from_url_only(%{String.t() => boolean()}, [struct()], keyword()) :: %{
|
@spec selection_from_url_only(%{String.t() => boolean()}, [struct()], keyword()) :: %{
|
||||||
String.t() => boolean()
|
String.t() => boolean()
|
||||||
|
|
@ -276,48 +222,16 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Merges user field selection with global settings.
|
Merges user field selection with global settings, scoped to the columns offered
|
||||||
|
for the given view settings (`:compact_member` / `:member_include_email` /
|
||||||
User selection takes priority over global settings. If a field is not in the
|
|
||||||
user selection, the global setting is used. If a field is not in global settings,
|
|
||||||
it defaults to `true` (visible).
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `user_selection` - Map of field names (strings) to boolean visibility
|
|
||||||
- `global_settings` - Settings struct with `member_field_visibility` field
|
|
||||||
- `custom_fields` - List of CustomField resources
|
|
||||||
|
|
||||||
## Returns
|
|
||||||
|
|
||||||
Map of field names (strings) to boolean visibility values
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> user_selection = %{"first_name" => false}
|
|
||||||
iex> settings = %{member_field_visibility: %{first_name: true, email: true}}
|
|
||||||
iex> merge_with_global_settings(user_selection, settings, [])
|
|
||||||
%{"first_name" => false, "email" => true} # User selection overrides global
|
|
||||||
"""
|
|
||||||
@spec merge_with_global_settings(
|
|
||||||
%{String.t() => boolean()},
|
|
||||||
map(),
|
|
||||||
[struct()]
|
|
||||||
) :: %{String.t() => boolean()}
|
|
||||||
def merge_with_global_settings(user_selection, global_settings, custom_fields) do
|
|
||||||
all_fields = get_all_available_fields(custom_fields)
|
|
||||||
do_merge(user_selection, global_settings, custom_fields, all_fields, @default_visible_fields)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Like `merge_with_global_settings/3`, but scoped to the columns offered for the
|
|
||||||
given view settings (`:compact_member` / `:member_include_email` /
|
|
||||||
`:compact_address` in `opts`).
|
`:compact_address` in `opts`).
|
||||||
|
|
||||||
Only offered columns appear in the result, so a column that is not applicable
|
User selection takes priority over global settings. If a field is not in the
|
||||||
in the current mode (e.g. `:first_name` while the composite "Name" is on) never
|
user selection, the global setting is used; if a field is not in global
|
||||||
leaks into the visible set. Defaults follow the mode via
|
settings, it defaults to visible. Only offered columns appear in the result, so
|
||||||
`default_visible_fields/2`.
|
a column that is not applicable in the current mode (e.g. `:first_name` while
|
||||||
|
the composite "Name" is on) never leaks into the visible set. Defaults follow
|
||||||
|
the mode via `default_visible_set/3`.
|
||||||
"""
|
"""
|
||||||
@spec merge_with_global_settings(%{String.t() => boolean()}, map(), [struct()], keyword()) ::
|
@spec merge_with_global_settings(%{String.t() => boolean()}, map(), [struct()], keyword()) ::
|
||||||
%{String.t() => boolean()}
|
%{String.t() => boolean()}
|
||||||
|
|
@ -345,35 +259,6 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
|
||||||
Gets the list of visible fields from a field selection map.
|
|
||||||
|
|
||||||
Returns only fields where visibility is `true`.
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `field_selection` - Map of field names to boolean visibility
|
|
||||||
|
|
||||||
## Returns
|
|
||||||
|
|
||||||
List of field identifiers (atoms for member fields, strings for custom fields)
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> selection = %{"first_name" => true, "email" => false, "street" => true}
|
|
||||||
iex> get_visible_fields(selection)
|
|
||||||
[:first_name, :street]
|
|
||||||
"""
|
|
||||||
@spec get_visible_fields(%{String.t() => boolean()}) :: [atom() | String.t()]
|
|
||||||
def get_visible_fields(field_selection) when is_map(field_selection) do
|
|
||||||
field_selection
|
|
||||||
|> Enum.filter(fn {_field, visible} -> visible end)
|
|
||||||
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|
|
||||||
|> Enum.uniq()
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_visible_fields(_), do: []
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets visible member fields from field selection.
|
Gets visible member fields from field selection.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,6 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
|
||||||
## Parameters
|
## Parameters
|
||||||
|
|
||||||
- `query` - Ash query for members
|
- `query` - Ash query for members
|
||||||
- `show_current` - If true, get current cycle status; if false, get last completed cycle status (currently unused, kept for API compatibility)
|
|
||||||
- `today` - Optional date to use as reference (currently unused, kept for API compatibility)
|
|
||||||
|
|
||||||
## Returns
|
## Returns
|
||||||
|
|
||||||
|
|
@ -33,8 +31,8 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
|
||||||
Uses Ash.Query.load to efficiently preload cycles in a single query.
|
Uses Ash.Query.load to efficiently preload cycles in a single query.
|
||||||
All cycles are loaded; filtering happens in memory in `get_cycle_status_for_member/2`.
|
All cycles are loaded; filtering happens in memory in `get_cycle_status_for_member/2`.
|
||||||
"""
|
"""
|
||||||
@spec load_cycles_for_members(Ash.Query.t(), boolean(), Date.t() | nil) :: Ash.Query.t()
|
@spec load_cycles_for_members(Ash.Query.t()) :: Ash.Query.t()
|
||||||
def load_cycles_for_members(query, _show_current \\ false, _today \\ nil) do
|
def load_cycles_for_members(query) do
|
||||||
# Load membership_fee_type and cycles
|
# Load membership_fee_type and cycles
|
||||||
query
|
query
|
||||||
|> Ash.Query.load([:membership_fee_type, membership_fee_cycles: [:membership_fee_type]])
|
|> Ash.Query.load([:membership_fee_type, membership_fee_cycles: [:membership_fee_type]])
|
||||||
|
|
@ -158,30 +156,6 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
|
||||||
Filters members by unpaid cycle status.
|
|
||||||
|
|
||||||
Returns members that have unpaid cycles in either the last completed cycle
|
|
||||||
or the current cycle, depending on `show_current`.
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `members` - List of member structs with loaded cycles
|
|
||||||
- `show_current` - If true, filter by current cycle; if false, filter by last completed cycle
|
|
||||||
|
|
||||||
## Returns
|
|
||||||
|
|
||||||
List of members with unpaid cycles
|
|
||||||
|
|
||||||
## Deprecated
|
|
||||||
|
|
||||||
This function is kept for backwards compatibility. Use `filter_members_by_cycle_status/3` instead.
|
|
||||||
"""
|
|
||||||
@spec filter_unpaid_members([Member.t()], boolean()) :: [Member.t()]
|
|
||||||
def filter_unpaid_members(members, show_current \\ false) do
|
|
||||||
filter_members_by_cycle_status(members, :unpaid, show_current)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Private helper function to format status label
|
# Private helper function to format status label
|
||||||
defp format_status_label(:paid), do: gettext("Paid")
|
defp format_status_label(:paid), do: gettext("Paid")
|
||||||
defp format_status_label(:unpaid), do: gettext("Unpaid")
|
defp format_status_label(:unpaid), do: gettext("Unpaid")
|
||||||
|
|
@ -21,6 +21,8 @@ defmodule MvWeb.MemberLive.Index.ViewSettings do
|
||||||
cookie is read directly from the request conn.
|
cookie is read directly from the request conn.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
alias MvWeb.MemberLive.Index.Cookie
|
||||||
|
|
||||||
@cookie_name "member_view_settings"
|
@cookie_name "member_view_settings"
|
||||||
@session_key "member_view_settings"
|
@session_key "member_view_settings"
|
||||||
@connect_param "view_settings"
|
@connect_param "view_settings"
|
||||||
|
|
@ -117,7 +119,7 @@ defmodule MvWeb.MemberLive.Index.ViewSettings do
|
||||||
case Plug.Conn.get_req_header(conn, "cookie") do
|
case Plug.Conn.get_req_header(conn, "cookie") do
|
||||||
[cookie_header | _rest] ->
|
[cookie_header | _rest] ->
|
||||||
cookie_header
|
cookie_header
|
||||||
|> parse_cookie_header()
|
|> Cookie.parse_header()
|
||||||
|> Map.get(@cookie_name)
|
|> Map.get(@cookie_name)
|
||||||
|> parse_json()
|
|> parse_json()
|
||||||
|
|
||||||
|
|
@ -170,17 +172,4 @@ defmodule MvWeb.MemberLive.Index.ViewSettings do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp parse_json(_), do: %{}
|
defp parse_json(_), do: %{}
|
||||||
|
|
||||||
# Parses a cookie header string into a name => value map.
|
|
||||||
defp parse_cookie_header(cookie_header) when is_binary(cookie_header) do
|
|
||||||
cookie_header
|
|
||||||
|> String.split(";")
|
|
||||||
|> Enum.map(&String.trim/1)
|
|
||||||
|> Enum.map(&String.split(&1, "=", parts: 2))
|
|
||||||
|> Enum.reduce(%{}, fn
|
|
||||||
[key, value], acc -> Map.put(acc, key, URI.decode(value))
|
|
||||||
[key], acc -> Map.put(acc, key, "")
|
|
||||||
_, acc -> acc
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
22
test/mv_web/live/member_live/index/cookie_test.exs
Normal file
22
test/mv_web/live/member_live/index/cookie_test.exs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
defmodule MvWeb.MemberLive.Index.CookieTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for the shared cookie-header parser used by the overview persistence chain.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias MvWeb.MemberLive.Index.Cookie
|
||||||
|
|
||||||
|
describe "parse_header/1" do
|
||||||
|
test "splits pairs and URL-decodes values" do
|
||||||
|
assert Cookie.parse_header("a=1; b=hello%20world") == %{"a" => "1", "b" => "hello world"}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "maps a valueless entry to an empty string" do
|
||||||
|
assert Cookie.parse_header("flag; a=1") == %{"flag" => "", "a" => "1"}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "keeps only the first '=' as the separator" do
|
||||||
|
assert Cookie.parse_header("token=a=b=c") == %{"token" => "a=b=c"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
87
test/mv_web/live/member_live/index/export_payload_test.exs
Normal file
87
test/mv_web/live/member_live/index/export_payload_test.exs
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
defmodule MvWeb.MemberLive.Index.ExportPayloadTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for the member-overview export descriptor builder: the column order it
|
||||||
|
emits must mirror the rendered table (DB fields in constants order, fee
|
||||||
|
type/status after the fee start date, groups before custom fields), and the
|
||||||
|
filter/sort/selection state must be serialized faithfully.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias MvWeb.MemberLive.Index.ExportPayload
|
||||||
|
|
||||||
|
defp custom_field(id), do: %{id: id}
|
||||||
|
|
||||||
|
describe "build/1" do
|
||||||
|
test "orders columns like the table: db fields, fee type/status, groups, custom fields" do
|
||||||
|
assigns = %{
|
||||||
|
selected_members: MapSet.new(["m1", "m2"]),
|
||||||
|
member_fields_visible_db: [:first_name, :membership_fee_start_date, :last_name],
|
||||||
|
member_fields_visible_computed: [:membership_fee_status],
|
||||||
|
member_fields_visible: [
|
||||||
|
:first_name,
|
||||||
|
:last_name,
|
||||||
|
:membership_fee_start_date,
|
||||||
|
:membership_fee_type,
|
||||||
|
:membership_fee_status,
|
||||||
|
:groups
|
||||||
|
],
|
||||||
|
visible_custom_field_ids: ["cf-a"],
|
||||||
|
all_custom_fields: [custom_field("cf-a"), custom_field("cf-b")],
|
||||||
|
query: "ada",
|
||||||
|
sort_field: :last_name,
|
||||||
|
sort_order: :desc,
|
||||||
|
show_current_cycle: true,
|
||||||
|
cycle_status_filter: :unpaid,
|
||||||
|
boolean_custom_field_filters: %{"cf-x" => true}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = ExportPayload.build(assigns)
|
||||||
|
|
||||||
|
# DB fields follow Mv.Constants.member_fields/0 order (first_name, last_name,
|
||||||
|
# then membership_fee_start_date), membership_fee_type + status are inserted
|
||||||
|
# right after the fee start date, groups precede the custom fields, and custom
|
||||||
|
# fields keep table order (only visible ones).
|
||||||
|
assert payload.column_order == [
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"membership_fee_start_date",
|
||||||
|
"membership_fee_type",
|
||||||
|
"membership_fee_status",
|
||||||
|
"groups",
|
||||||
|
"cf-a"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert Enum.sort(payload.selected_ids) == ["m1", "m2"]
|
||||||
|
assert payload.custom_field_ids == ["cf-a"]
|
||||||
|
assert payload.query == "ada"
|
||||||
|
assert payload.sort_field == "last_name"
|
||||||
|
assert payload.sort_order == "desc"
|
||||||
|
assert payload.show_current_cycle == true
|
||||||
|
assert payload.cycle_status_filter == "unpaid"
|
||||||
|
assert payload.boolean_filters == %{"cf-x" => true}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "defaults optional state and omits groups/custom fields when not visible" do
|
||||||
|
assigns = %{
|
||||||
|
selected_members: MapSet.new(),
|
||||||
|
member_fields_visible_db: [:first_name],
|
||||||
|
member_fields_visible_computed: [],
|
||||||
|
member_fields_visible: [:first_name],
|
||||||
|
visible_custom_field_ids: [],
|
||||||
|
all_custom_fields: [custom_field("cf-a")]
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = ExportPayload.build(assigns)
|
||||||
|
|
||||||
|
assert payload.column_order == ["first_name"]
|
||||||
|
assert payload.selected_ids == []
|
||||||
|
assert payload.custom_field_ids == []
|
||||||
|
assert payload.query == nil
|
||||||
|
assert payload.sort_field == nil
|
||||||
|
assert payload.sort_order == nil
|
||||||
|
assert payload.show_current_cycle == false
|
||||||
|
assert payload.cycle_status_filter == nil
|
||||||
|
assert payload.boolean_filters == %{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -6,201 +6,6 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
|
||||||
|
|
||||||
alias MvWeb.MemberLive.Index.FieldVisibility
|
alias MvWeb.MemberLive.Index.FieldVisibility
|
||||||
|
|
||||||
# Mock custom field structs for testing
|
|
||||||
defp create_custom_field(id, name, show_in_overview \\ true) do
|
|
||||||
%{
|
|
||||||
id: id,
|
|
||||||
name: name,
|
|
||||||
show_in_overview: show_in_overview
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "get_all_available_fields/1" do
|
|
||||||
test "returns member fields and custom fields" do
|
|
||||||
custom_fields = [
|
|
||||||
create_custom_field("cf1", "Custom Field 1"),
|
|
||||||
create_custom_field("cf2", "Custom Field 2")
|
|
||||||
]
|
|
||||||
|
|
||||||
result = FieldVisibility.get_all_available_fields(custom_fields)
|
|
||||||
|
|
||||||
# Should include all member fields
|
|
||||||
assert :first_name in result
|
|
||||||
assert :email in result
|
|
||||||
assert :street in result
|
|
||||||
|
|
||||||
# Should include custom fields as strings
|
|
||||||
assert "custom_field_cf1" in result
|
|
||||||
assert "custom_field_cf2" in result
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles empty custom fields list" do
|
|
||||||
result = FieldVisibility.get_all_available_fields([])
|
|
||||||
|
|
||||||
# Should only have member fields
|
|
||||||
assert :first_name in result
|
|
||||||
assert :email in result
|
|
||||||
|
|
||||||
refute Enum.any?(result, fn field ->
|
|
||||||
is_binary(field) and String.starts_with?(field, "custom_field_")
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "includes all member fields from constants" do
|
|
||||||
custom_fields = []
|
|
||||||
result = FieldVisibility.get_all_available_fields(custom_fields)
|
|
||||||
|
|
||||||
member_fields = Mv.Constants.member_fields()
|
|
||||||
|
|
||||||
Enum.each(member_fields, fn field ->
|
|
||||||
assert field in result
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "includes pseudo member fields (membership_fee_status, membership_fee_type, groups)" do
|
|
||||||
result = FieldVisibility.get_all_available_fields([])
|
|
||||||
|
|
||||||
assert :membership_fee_status in result
|
|
||||||
assert :membership_fee_type in result
|
|
||||||
assert :groups in result
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "merge_with_global_settings/3" do
|
|
||||||
test "user selection overrides global settings" do
|
|
||||||
user_selection = %{"first_name" => false}
|
|
||||||
settings = %{member_field_visibility: %{first_name: true, email: true}}
|
|
||||||
custom_fields = []
|
|
||||||
|
|
||||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
|
||||||
|
|
||||||
assert result["first_name"] == false
|
|
||||||
assert result["email"] == true
|
|
||||||
end
|
|
||||||
|
|
||||||
test "falls back to global settings when user selection is empty" do
|
|
||||||
user_selection = %{}
|
|
||||||
settings = %{member_field_visibility: %{first_name: false, email: true}}
|
|
||||||
custom_fields = []
|
|
||||||
|
|
||||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
|
||||||
|
|
||||||
assert result["first_name"] == false
|
|
||||||
assert result["email"] == true
|
|
||||||
end
|
|
||||||
|
|
||||||
test "defaults to the curated set when field not in settings" do
|
|
||||||
user_selection = %{}
|
|
||||||
settings = %{member_field_visibility: %{join_date: false}}
|
|
||||||
custom_fields = []
|
|
||||||
|
|
||||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
|
||||||
|
|
||||||
# join_date from settings overrides its curated default
|
|
||||||
assert result["join_date"] == false
|
|
||||||
# name is in the curated default set -> visible
|
|
||||||
assert result["name"] == true
|
|
||||||
# email is not in the curated default set -> hidden
|
|
||||||
assert result["email"] == false
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles custom fields visibility" do
|
|
||||||
user_selection = %{}
|
|
||||||
settings = %{member_field_visibility: %{}}
|
|
||||||
|
|
||||||
custom_fields = [
|
|
||||||
create_custom_field("cf1", "Custom 1", true),
|
|
||||||
create_custom_field("cf2", "Custom 2", false)
|
|
||||||
]
|
|
||||||
|
|
||||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
|
||||||
|
|
||||||
assert result["custom_field_cf1"] == true
|
|
||||||
assert result["custom_field_cf2"] == false
|
|
||||||
end
|
|
||||||
|
|
||||||
test "user selection overrides custom field visibility" do
|
|
||||||
user_selection = %{"custom_field_cf1" => false}
|
|
||||||
settings = %{member_field_visibility: %{}}
|
|
||||||
|
|
||||||
custom_fields = [
|
|
||||||
create_custom_field("cf1", "Custom 1", true)
|
|
||||||
]
|
|
||||||
|
|
||||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
|
||||||
|
|
||||||
assert result["custom_field_cf1"] == false
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles string keys in settings (JSONB format)" do
|
|
||||||
user_selection = %{}
|
|
||||||
settings = %{member_field_visibility: %{"first_name" => false, "email" => true}}
|
|
||||||
custom_fields = []
|
|
||||||
|
|
||||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
|
||||||
|
|
||||||
assert result["first_name"] == false
|
|
||||||
assert result["email"] == true
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles mixed atom and string keys in settings" do
|
|
||||||
user_selection = %{}
|
|
||||||
# Use string keys only (as JSONB would return)
|
|
||||||
settings = %{member_field_visibility: %{"first_name" => false, "email" => true}}
|
|
||||||
custom_fields = []
|
|
||||||
|
|
||||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
|
||||||
|
|
||||||
assert result["first_name"] == false
|
|
||||||
assert result["email"] == true
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles nil settings gracefully" do
|
|
||||||
user_selection = %{}
|
|
||||||
settings = %{member_field_visibility: nil}
|
|
||||||
custom_fields = []
|
|
||||||
|
|
||||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
|
||||||
|
|
||||||
# Falls back to the curated default set (no crash on nil)
|
|
||||||
assert result["join_date"] == true
|
|
||||||
assert result["email"] == false
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles missing member_field_visibility key" do
|
|
||||||
user_selection = %{}
|
|
||||||
settings = %{}
|
|
||||||
custom_fields = []
|
|
||||||
|
|
||||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
|
||||||
|
|
||||||
# Falls back to the curated default set
|
|
||||||
assert result["join_date"] == true
|
|
||||||
assert result["email"] == false
|
|
||||||
end
|
|
||||||
|
|
||||||
test "includes all fields in result" do
|
|
||||||
user_selection = %{"first_name" => false}
|
|
||||||
settings = %{member_field_visibility: %{email: true}}
|
|
||||||
|
|
||||||
custom_fields = [
|
|
||||||
create_custom_field("cf1", "Custom 1", true)
|
|
||||||
]
|
|
||||||
|
|
||||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
|
||||||
|
|
||||||
# Should include all member fields
|
|
||||||
member_fields = Mv.Constants.member_fields()
|
|
||||||
|
|
||||||
Enum.each(member_fields, fn field ->
|
|
||||||
assert Map.has_key?(result, Atom.to_string(field))
|
|
||||||
end)
|
|
||||||
|
|
||||||
# Should include custom fields
|
|
||||||
assert Map.has_key?(result, "custom_field_cf1")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "offered_member_fields/3 ordering (name group, then email, then address group, then rest)" do
|
describe "offered_member_fields/3 ordering (name group, then email, then address group, then rest)" do
|
||||||
test "compact member + compact address: Name, E-Mail, Adresse lead" do
|
test "compact member + compact address: Name, E-Mail, Adresse lead" do
|
||||||
result = FieldVisibility.offered_member_fields(true, false, true)
|
result = FieldVisibility.offered_member_fields(true, false, true)
|
||||||
|
|
@ -251,55 +56,6 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
|
||||||
|
|
||||||
defp index_of(list, elem), do: Enum.find_index(list, &(&1 == elem))
|
defp index_of(list, elem), do: Enum.find_index(list, &(&1 == elem))
|
||||||
|
|
||||||
describe "get_visible_fields/1" do
|
|
||||||
test "returns only fields with true visibility" do
|
|
||||||
selection = %{
|
|
||||||
"first_name" => true,
|
|
||||||
"email" => false,
|
|
||||||
"street" => true,
|
|
||||||
"custom_field_123" => false
|
|
||||||
}
|
|
||||||
|
|
||||||
result = FieldVisibility.get_visible_fields(selection)
|
|
||||||
|
|
||||||
assert :first_name in result
|
|
||||||
assert :street in result
|
|
||||||
refute :email in result
|
|
||||||
refute "custom_field_123" in result
|
|
||||||
end
|
|
||||||
|
|
||||||
test "converts member field strings to atoms" do
|
|
||||||
selection = %{"first_name" => true, "email" => true}
|
|
||||||
|
|
||||||
result = FieldVisibility.get_visible_fields(selection)
|
|
||||||
|
|
||||||
assert :first_name in result
|
|
||||||
assert :email in result
|
|
||||||
end
|
|
||||||
|
|
||||||
test "keeps custom fields as strings" do
|
|
||||||
selection = %{"custom_field_abc-123" => true}
|
|
||||||
|
|
||||||
result = FieldVisibility.get_visible_fields(selection)
|
|
||||||
|
|
||||||
assert "custom_field_abc-123" in result
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles empty selection" do
|
|
||||||
assert FieldVisibility.get_visible_fields(%{}) == []
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles all fields hidden" do
|
|
||||||
selection = %{"first_name" => false, "email" => false}
|
|
||||||
|
|
||||||
assert FieldVisibility.get_visible_fields(selection) == []
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles invalid input" do
|
|
||||||
assert FieldVisibility.get_visible_fields(nil) == []
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "get_visible_member_fields/1" do
|
describe "get_visible_member_fields/1" do
|
||||||
test "returns only member fields that are visible" do
|
test "returns only member fields that are visible" do
|
||||||
selection = %{
|
selection = %{
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
||||||
|
|
||||||
import Mv.Fixtures, only: [create_fee_type: 1, create_cycle: 3]
|
import Mv.Fixtures, only: [create_fee_type: 1, create_cycle: 3]
|
||||||
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
@ -26,35 +25,6 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
||||||
member
|
member
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "load_cycles_for_members/2" do
|
|
||||||
test "efficiently loads cycles for members" do
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
member1 = create_member(%{membership_fee_type_id: fee_type.id})
|
|
||||||
member2 = create_member(%{membership_fee_type_id: fee_type.id})
|
|
||||||
|
|
||||||
create_cycle(member1, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
|
|
||||||
create_cycle(member2, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
|
||||||
|
|
||||||
query =
|
|
||||||
Member
|
|
||||||
|> Ash.Query.filter(id in [^member1.id, ^member2.id])
|
|
||||||
|> MembershipFeeStatus.load_cycles_for_members()
|
|
||||||
|
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
||||||
members = Ash.read!(query, actor: system_actor)
|
|
||||||
|
|
||||||
assert length(members) == 2
|
|
||||||
|
|
||||||
# Verify cycles are loaded
|
|
||||||
member1_loaded = Enum.find(members, &(&1.id == member1.id))
|
|
||||||
member2_loaded = Enum.find(members, &(&1.id == member2.id))
|
|
||||||
|
|
||||||
assert member1_loaded.membership_fee_cycles != nil
|
|
||||||
assert member2_loaded.membership_fee_cycles != nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "get_cycle_status_for_member/2" do
|
describe "get_cycle_status_for_member/2" do
|
||||||
test "returns status of last completed cycle" do
|
test "returns status of last completed cycle" do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly})
|
||||||
|
|
@ -339,27 +309,5 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
||||||
assert length(filtered) == 1
|
assert length(filtered) == 1
|
||||||
assert List.first(filtered).id == member2.id
|
assert List.first(filtered).id == member2.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns all members when filter is nil" do
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
member1 = create_member(%{membership_fee_type_id: fee_type.id})
|
|
||||||
member2 = create_member(%{membership_fee_type_id: fee_type.id})
|
|
||||||
|
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
||||||
|
|
||||||
members =
|
|
||||||
[member1, member2]
|
|
||||||
|> Enum.map(fn m ->
|
|
||||||
m
|
|
||||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|
|
||||||
|> Ash.load!(:membership_fee_type, actor: system_actor)
|
|
||||||
end)
|
|
||||||
|
|
||||||
# filter_unpaid_members should still work for backwards compatibility
|
|
||||||
filtered = MembershipFeeStatus.filter_unpaid_members(members, false)
|
|
||||||
|
|
||||||
# Both members have no cycles, so both should be filtered out
|
|
||||||
assert Enum.empty?(filtered)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -95,6 +95,9 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
||||||
html = render(view)
|
html = render(view)
|
||||||
# Should show suspended status (current cycle)
|
# Should show suspended status (current cycle)
|
||||||
assert html =~ "hero-pause-circle" || html =~ "suspended"
|
assert html =~ "hero-pause-circle" || html =~ "suspended"
|
||||||
|
|
||||||
|
# The toggle button exposes its pressed state to assistive tech.
|
||||||
|
assert has_element?(view, ~s([data-testid=toggle-cycle-view][aria-pressed="true"]))
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows correct color coding for paid status", %{conn: conn} do
|
test "shows correct color coding for paid status", %{conn: conn} do
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,10 @@ defmodule MvWeb.MemberLive.IndexPaginationTest do
|
||||||
|
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
|
|
||||||
alias Mv.Helpers.SystemActor
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
# Matches the :overview default_limit / @page_limit in the LiveView.
|
# Matches the :overview default_limit / @page_limit in the LiveView.
|
||||||
@page_limit 50
|
@page_limit 50
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -708,27 +708,6 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
# Flash message should appear
|
# Flash message should appear
|
||||||
assert has_element?(view, "#flash-group")
|
assert has_element?(view, "#flash-group")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "copy excludes a member whose email is blank from the recipient list", %{conn: conn} do
|
|
||||||
# The Member create action requires an email, so a blank-email member cannot
|
|
||||||
# be persisted; we exercise the preserved defensive filter in
|
|
||||||
# format_selected_member_emails/2 directly. One member has an email, the
|
|
||||||
# other has a blank one — only the former is a recipient (§1.10).
|
|
||||||
with_email = %{
|
|
||||||
id: Ecto.UUID.generate(),
|
|
||||||
first_name: "Has",
|
|
||||||
last_name: "Mail",
|
|
||||||
email: "has@example.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
blank_email = %{id: Ecto.UUID.generate(), first_name: "Blank", last_name: "Mail", email: ""}
|
|
||||||
selected = MapSet.new([with_email.id, blank_email.id])
|
|
||||||
|
|
||||||
emails = MemberIndex.format_selected_member_emails([with_email, blank_email], selected)
|
|
||||||
|
|
||||||
assert emails == ["Has Mail <has@example.com>"]
|
|
||||||
_ = conn
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "copy_emails empty-recipient feedback" do
|
describe "copy_emails empty-recipient feedback" do
|
||||||
|
|
@ -1800,153 +1779,6 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|> Ash.load!(:custom_field_values, actor: actor)
|
|> Ash.load!(:custom_field_values, actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Tests for get_boolean_custom_field_value/2
|
|
||||||
test "get_boolean_custom_field_value extracts true from Ash.Union format", %{conn: _conn} do
|
|
||||||
system_actor = SystemActor.get_system_actor()
|
|
||||||
boolean_field = create_boolean_custom_field()
|
|
||||||
member = create_member_with_boolean_value(%{}, boolean_field, true, system_actor)
|
|
||||||
|
|
||||||
# Test the function (will fail until implemented)
|
|
||||||
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
|
|
||||||
|
|
||||||
assert result == true
|
|
||||||
end
|
|
||||||
|
|
||||||
test "get_boolean_custom_field_value extracts false from Ash.Union format", %{conn: _conn} do
|
|
||||||
system_actor = SystemActor.get_system_actor()
|
|
||||||
boolean_field = create_boolean_custom_field()
|
|
||||||
member = create_member_with_boolean_value(%{}, boolean_field, false, system_actor)
|
|
||||||
|
|
||||||
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
|
|
||||||
|
|
||||||
assert result == false
|
|
||||||
end
|
|
||||||
|
|
||||||
test "get_boolean_custom_field_value extracts true from map format with _union_type and _union_value keys",
|
|
||||||
%{conn: _conn} do
|
|
||||||
system_actor = SystemActor.get_system_actor()
|
|
||||||
boolean_field = create_boolean_custom_field()
|
|
||||||
|
|
||||||
{:ok, member} =
|
|
||||||
Membership.create_member(
|
|
||||||
%{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "Member",
|
|
||||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
|
||||||
},
|
|
||||||
actor: system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create CustomFieldValue with map format (Ash expects _union_type and _union_value)
|
|
||||||
{:ok, _cfv} =
|
|
||||||
CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member.id,
|
|
||||||
custom_field_id: boolean_field.id,
|
|
||||||
value: %{"_union_type" => "boolean", "_union_value" => true}
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: system_actor)
|
|
||||||
|
|
||||||
# Reload member with custom field values
|
|
||||||
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
|
|
||||||
|
|
||||||
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
|
|
||||||
|
|
||||||
assert result == true
|
|
||||||
end
|
|
||||||
|
|
||||||
test "get_boolean_custom_field_value returns nil when no CustomFieldValue exists", %{
|
|
||||||
conn: _conn
|
|
||||||
} do
|
|
||||||
system_actor = SystemActor.get_system_actor()
|
|
||||||
boolean_field = create_boolean_custom_field()
|
|
||||||
|
|
||||||
{:ok, member} =
|
|
||||||
Membership.create_member(
|
|
||||||
%{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "Member",
|
|
||||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
|
||||||
},
|
|
||||||
actor: system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
# Member has no custom field value for this field
|
|
||||||
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
|
|
||||||
|
|
||||||
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
|
|
||||||
|
|
||||||
assert result == nil
|
|
||||||
end
|
|
||||||
|
|
||||||
test "get_boolean_custom_field_value returns nil when CustomFieldValue has nil value", %{
|
|
||||||
conn: _conn
|
|
||||||
} do
|
|
||||||
system_actor = SystemActor.get_system_actor()
|
|
||||||
boolean_field = create_boolean_custom_field()
|
|
||||||
|
|
||||||
{:ok, member} =
|
|
||||||
Membership.create_member(
|
|
||||||
%{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "Member",
|
|
||||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
|
||||||
},
|
|
||||||
actor: system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create CustomFieldValue with nil value (edge case)
|
|
||||||
{:ok, _cfv} =
|
|
||||||
CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member.id,
|
|
||||||
custom_field_id: boolean_field.id,
|
|
||||||
value: nil
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: system_actor)
|
|
||||||
|
|
||||||
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
|
|
||||||
|
|
||||||
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
|
|
||||||
|
|
||||||
assert result == nil
|
|
||||||
end
|
|
||||||
|
|
||||||
test "get_boolean_custom_field_value returns nil for non-boolean CustomFieldValue", %{
|
|
||||||
conn: _conn
|
|
||||||
} do
|
|
||||||
system_actor = SystemActor.get_system_actor()
|
|
||||||
string_field = create_string_custom_field()
|
|
||||||
boolean_field = create_boolean_custom_field()
|
|
||||||
|
|
||||||
{:ok, member} =
|
|
||||||
Membership.create_member(
|
|
||||||
%{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "Member",
|
|
||||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
|
||||||
},
|
|
||||||
actor: system_actor
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create string custom field value (not boolean)
|
|
||||||
{:ok, _cfv} =
|
|
||||||
CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member.id,
|
|
||||||
custom_field_id: string_field.id,
|
|
||||||
value: %{"_union_type" => "string", "_union_value" => "test"}
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: system_actor)
|
|
||||||
|
|
||||||
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
|
|
||||||
|
|
||||||
# Try to get boolean value from string field - should return nil
|
|
||||||
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
|
|
||||||
|
|
||||||
assert result == nil
|
|
||||||
end
|
|
||||||
|
|
||||||
# Tests for apply_boolean_custom_field_filters/2
|
# Tests for apply_boolean_custom_field_filters/2
|
||||||
test "apply_boolean_custom_field_filters filters members with true value and excludes false/without values",
|
test "apply_boolean_custom_field_filters filters members with true value and excludes false/without values",
|
||||||
%{conn: _conn} do
|
%{conn: _conn} do
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue