refactor(member-live): modularize the overview and drop the superseded in-memory paths
Extract the cookie parser, export-payload builder and fee-status helper into their own modules and remove the filter/sort/load helpers the database pushdown made dead, so the overview LiveView stays a thin coordinator over focused units.
This commit is contained in:
parent
c2cb3edab8
commit
77fc11a0b0
17 changed files with 375 additions and 875 deletions
|
|
@ -35,6 +35,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
alias MvWeb.Helpers.DateFormatter
|
||||
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
|
||||
alias MvWeb.MemberLive.Index.DateFilter
|
||||
alias MvWeb.MemberLive.Index.ExportPayload
|
||||
alias MvWeb.MemberLive.Index.FieldSelection
|
||||
alias MvWeb.MemberLive.Index.FieldVisibility
|
||||
alias MvWeb.MemberLive.Index.FilterParams
|
||||
|
|
@ -377,8 +378,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
query_params =
|
||||
build_query_params(
|
||||
opts_for_query_params(socket, %{
|
||||
sort_field: export_sort_field(socket.assigns.sort_field),
|
||||
sort_order: export_sort_order(socket.assigns.sort_order)
|
||||
sort_field: ExportPayload.sort_field(socket.assigns.sort_field),
|
||||
sort_order: ExportPayload.sort_order(socket.assigns.sort_order)
|
||||
})
|
||||
)
|
||||
|> maybe_add_field_selection(
|
||||
|
|
@ -553,53 +554,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
{:noreply, push_reload(socket, new_path)}
|
||||
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
|
||||
socket =
|
||||
socket
|
||||
|
|
@ -1348,7 +1302,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> OverviewQuery.build()
|
||||
|> Ash.Query.select(@overview_fields)
|
||||
|> 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])
|
||||
|> maybe_load_fee_type(socket)
|
||||
end
|
||||
|
|
@ -1490,8 +1444,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
String.starts_with?(field, @custom_field_prefix)
|
||||
end
|
||||
|
||||
defp custom_field_sort?(_), do: false
|
||||
|
||||
defp extract_custom_field_ids(visible_custom_fields) do
|
||||
Enum.map(visible_custom_fields, fn field_string ->
|
||||
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)
|
||||
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}),
|
||||
do: extract_boolean_value(value)
|
||||
|
||||
|
|
@ -1843,14 +1788,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
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 format_member_email(member) do
|
||||
|
|
@ -1997,149 +1934,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
|
||||
defp assign_export_payload(socket) do
|
||||
payload = build_export_payload(socket)
|
||||
payload = ExportPayload.build(socket.assigns)
|
||||
assign(socket, :export_payload_json, Jason.encode!(payload))
|
||||
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
|
||||
|
|
|
|||
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`
|
||||
"""
|
||||
|
||||
alias MvWeb.MemberLive.Index.Cookie
|
||||
|
||||
@cookie_name "member_field_selection"
|
||||
@cookie_max_age 365 * 24 * 60 * 60
|
||||
@session_key "member_field_selection"
|
||||
|
|
@ -80,7 +82,7 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
|
|||
%{}
|
||||
|
||||
[cookie_header | _rest] ->
|
||||
cookies = parse_cookie_header(cookie_header)
|
||||
cookies = Cookie.parse_header(cookie_header)
|
||||
|
||||
case Map.get(cookies, @cookie_name) do
|
||||
nil -> %{}
|
||||
|
|
@ -90,19 +92,6 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
|
|||
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 """
|
||||
Saves field selection to cookie.
|
||||
|
||||
|
|
|
|||
|
|
@ -60,30 +60,12 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
:join_date
|
||||
])
|
||||
|
||||
@doc """
|
||||
The curated default-visible columns (member-field atoms) for a first visit.
|
||||
"""
|
||||
@spec default_visible_fields() :: [atom()]
|
||||
def default_visible_fields, do: MapSet.to_list(@default_visible_fields)
|
||||
|
||||
@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
|
||||
|
||||
# The curated default-visible column set for the given view settings. In
|
||||
# compact mode the composite `:name` / `:address` columns are 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`).
|
||||
defp default_visible_set(compact_member, include_email, compact_address) do
|
||||
@default_visible_fields
|
||||
|> apply_name_default(compact_member, include_email)
|
||||
|
|
@ -205,48 +187,12 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
end
|
||||
|
||||
@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:
|
||||
- Member fields as atoms (e.g., `:first_name`, `:email`)
|
||||
- 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`).
|
||||
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()], keyword()) :: %{
|
||||
String.t() => boolean()
|
||||
|
|
@ -276,48 +222,16 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
end
|
||||
|
||||
@doc """
|
||||
Merges user field selection with global settings.
|
||||
|
||||
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` /
|
||||
Merges user field selection with global settings, scoped to the columns offered
|
||||
for the given view settings (`:compact_member` / `:member_include_email` /
|
||||
`:compact_address` in `opts`).
|
||||
|
||||
Only offered columns appear in the result, so 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_fields/2`.
|
||||
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 visible. Only offered columns appear in the result, so
|
||||
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()) ::
|
||||
%{String.t() => boolean()}
|
||||
|
|
@ -345,35 +259,6 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
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 """
|
||||
Gets visible member fields from field selection.
|
||||
|
||||
|
|
|
|||
163
lib/mv_web/live/member_live/index/membership_fee_status.ex
Normal file
163
lib/mv_web/live/member_live/index/membership_fee_status.ex
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
|
||||
@moduledoc """
|
||||
Helper module for membership fee status display in member list view.
|
||||
|
||||
Provides functions to efficiently load and determine cycle status for members
|
||||
in the list view, avoiding N+1 queries.
|
||||
"""
|
||||
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
@doc """
|
||||
Loads membership fee cycles for members efficiently.
|
||||
|
||||
Preloads cycles with membership_fee_type relationship to avoid N+1 queries.
|
||||
Note: This loads all cycles for each member. The filtering to get the relevant
|
||||
cycle (current or last completed) happens in `get_cycle_status_for_member/2`.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `query` - Ash query for members
|
||||
|
||||
## Returns
|
||||
|
||||
Modified query with cycles loaded
|
||||
|
||||
## Performance
|
||||
|
||||
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`.
|
||||
"""
|
||||
@spec load_cycles_for_members(Ash.Query.t()) :: Ash.Query.t()
|
||||
def load_cycles_for_members(query) do
|
||||
# Load membership_fee_type and cycles
|
||||
query
|
||||
|> Ash.Query.load([:membership_fee_type, membership_fee_cycles: [:membership_fee_type]])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the cycle status for a member.
|
||||
|
||||
Returns the status of either the last completed cycle or the current cycle,
|
||||
depending on the `show_current` parameter.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `member` - Member struct with loaded cycles and membership_fee_type
|
||||
- `show_current` - If true, get current cycle status; if false, get last completed cycle status
|
||||
|
||||
## Returns
|
||||
|
||||
- `:paid`, `:unpaid`, or `:suspended` if cycle exists
|
||||
- `nil` if no cycle exists
|
||||
|
||||
## Examples
|
||||
|
||||
# Get last completed cycle status
|
||||
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, false)
|
||||
:paid
|
||||
|
||||
# Get current cycle status
|
||||
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, true)
|
||||
:unpaid
|
||||
"""
|
||||
@spec get_cycle_status_for_member(Member.t(), boolean(), Date.t() | nil) ::
|
||||
:paid | :unpaid | :suspended | nil
|
||||
def get_cycle_status_for_member(member, show_current \\ false, today \\ nil) do
|
||||
cycle =
|
||||
if show_current do
|
||||
MembershipFeeHelpers.get_current_cycle(member, today)
|
||||
else
|
||||
MembershipFeeHelpers.get_last_completed_cycle(member, today)
|
||||
end
|
||||
|
||||
case cycle do
|
||||
nil -> nil
|
||||
cycle -> cycle.status
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Formats cycle status as a badge component.
|
||||
|
||||
Returns a map with badge information for rendering in templates.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `status` - Cycle status (`:paid`, `:unpaid`, `:suspended`, or `nil`)
|
||||
|
||||
## Returns
|
||||
|
||||
Map with `:variant`, `:icon`, and `:label` keys (and legacy `:color`), or `nil` if status is nil.
|
||||
Use `:variant` with <.badge variant={badge.variant}> for WCAG-compliant rendering.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(:paid)
|
||||
%{variant: :success, color: "badge-success", icon: "hero-check-circle", label: "Paid"}
|
||||
|
||||
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(nil)
|
||||
nil
|
||||
"""
|
||||
@spec format_cycle_status_badge(:paid | :unpaid | :suspended | nil) ::
|
||||
%{
|
||||
variant: :success | :error | :warning,
|
||||
color: String.t(),
|
||||
icon: String.t(),
|
||||
label: String.t()
|
||||
}
|
||||
| nil
|
||||
def format_cycle_status_badge(nil), do: nil
|
||||
|
||||
def format_cycle_status_badge(status) when status in [:paid, :unpaid, :suspended] do
|
||||
%{
|
||||
variant: MembershipFeeHelpers.status_variant(status),
|
||||
color: MembershipFeeHelpers.status_color(status),
|
||||
icon: MembershipFeeHelpers.status_icon(status),
|
||||
label: format_status_label(status)
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Filters members by cycle status (paid or unpaid).
|
||||
|
||||
Returns members that have the specified status in either the last completed cycle
|
||||
or the current cycle, depending on `show_current`.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `members` - List of member structs with loaded cycles
|
||||
- `status` - Cycle status to filter by (`:paid` or `:unpaid`)
|
||||
- `show_current` - If true, filter by current cycle; if false, filter by last completed cycle
|
||||
|
||||
## Returns
|
||||
|
||||
List of members with the specified cycle status
|
||||
|
||||
## Examples
|
||||
|
||||
# Filter unpaid members in last cycle
|
||||
iex> filter_members_by_cycle_status(members, :unpaid, false)
|
||||
[%Member{}, ...]
|
||||
|
||||
# Filter paid members in current cycle
|
||||
iex> filter_members_by_cycle_status(members, :paid, true)
|
||||
[%Member{}, ...]
|
||||
"""
|
||||
@spec filter_members_by_cycle_status([Member.t()], :paid | :unpaid, boolean()) :: [Member.t()]
|
||||
def filter_members_by_cycle_status(members, status, show_current \\ false)
|
||||
when status in [:paid, :unpaid] do
|
||||
Enum.filter(members, fn member ->
|
||||
member_status = get_cycle_status_for_member(member, show_current)
|
||||
member_status == status
|
||||
end)
|
||||
end
|
||||
|
||||
# Private helper function to format status label
|
||||
defp format_status_label(:paid), do: gettext("Paid")
|
||||
defp format_status_label(:unpaid), do: gettext("Unpaid")
|
||||
defp format_status_label(:suspended), do: gettext("Suspended")
|
||||
end
|
||||
|
|
@ -21,6 +21,8 @@ defmodule MvWeb.MemberLive.Index.ViewSettings do
|
|||
cookie is read directly from the request conn.
|
||||
"""
|
||||
|
||||
alias MvWeb.MemberLive.Index.Cookie
|
||||
|
||||
@cookie_name "member_view_settings"
|
||||
@session_key "member_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
|
||||
[cookie_header | _rest] ->
|
||||
cookie_header
|
||||
|> parse_cookie_header()
|
||||
|> Cookie.parse_header()
|
||||
|> Map.get(@cookie_name)
|
||||
|> parse_json()
|
||||
|
||||
|
|
@ -170,17 +172,4 @@ defmodule MvWeb.MemberLive.Index.ViewSettings do
|
|||
end
|
||||
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue