From 77fc11a0b0f203e20b0385d8de239562dea85b39 Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 6 Jul 2026 10:54:09 +0200 Subject: [PATCH] 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. --- lib/mv/membership/member_export/build.ex | 8 +- lib/mv_web/components/core_components.ex | 31 ++- .../controllers/member_export_controller.ex | 8 +- lib/mv_web/live/member_live/index.ex | 215 +-------------- lib/mv_web/live/member_live/index/cookie.ex | 28 ++ .../live/member_live/index/export_payload.ex | 163 ++++++++++++ .../live/member_live/index/field_selection.ex | 17 +- .../member_live/index/field_visibility.ex | 153 ++--------- .../index/membership_fee_status.ex | 30 +-- .../live/member_live/index/view_settings.ex | 17 +- .../live/member_live/index/cookie_test.exs | 22 ++ .../member_live/index/export_payload_test.exs | 87 +++++++ .../index/field_visibility_test.exs | 244 ------------------ .../index/membership_fee_status_test.exs | 52 ---- .../index_membership_fee_status_test.exs | 3 + .../member_live/index_pagination_test.exs | 4 +- test/mv_web/member_live/index_test.exs | 168 ------------ 17 files changed, 375 insertions(+), 875 deletions(-) create mode 100644 lib/mv_web/live/member_live/index/cookie.ex create mode 100644 lib/mv_web/live/member_live/index/export_payload.ex rename lib/mv_web/{ => live}/member_live/index/membership_fee_status.ex (81%) create mode 100644 test/mv_web/live/member_live/index/cookie_test.exs create mode 100644 test/mv_web/live/member_live/index/export_payload_test.exs rename test/mv_web/{ => live}/member_live/index/membership_fee_status_test.exs (85%) diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex index 9f848a3b..f1af5dbc 100644 --- a/lib/mv/membership/member_export/build.ex +++ b/lib/mv/membership/member_export/build.ex @@ -145,7 +145,7 @@ defmodule Mv.Membership.MemberExport.Build do |> Ash.Query.new() |> Ash.Query.select(select_fields) |> 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_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 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 - MembershipFeeStatus.load_cycles_for_members(query, show_current) + defp maybe_load_cycles(query, true) do + MembershipFeeStatus.load_cycles_for_members(query) end defp maybe_load_groups(query, false), do: query diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index f8b95f93..b95ba33e 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -143,12 +143,18 @@ defmodule MvWeb.CoreComponents do attr :size, :string, values: ~w(sm md lg), default: "md" 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 def button(assigns) do rest = assigns.rest variant = assigns[:variant] || "primary" size = assigns[:size] || "md" + active_class = if assigns[:active], do: "btn-active", else: "" variant_classes = %{ "primary" => "btn-primary", @@ -169,7 +175,9 @@ defmodule MvWeb.CoreComponents do base_class = Map.fetch!(variant_classes, variant) 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 @@ -214,6 +222,27 @@ defmodule MvWeb.CoreComponents do 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""" + + """ + end + @doc """ Renders a non-interactive badge with WCAG-compliant contrast. diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex index e33b2115..a7683b4a 100644 --- a/lib/mv_web/controllers/member_export_controller.ex +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -294,7 +294,7 @@ defmodule MvWeb.MemberExportController do |> Ash.Query.new() |> Ash.Query.select(select_fields) |> 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_membership_fee_type(need_membership_fee_type) @@ -338,10 +338,10 @@ defmodule MvWeb.MemberExportController do 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 - MembershipFeeStatus.load_cycles_for_members(query, show_current) + defp maybe_load_cycles(query, true) do + MembershipFeeStatus.load_cycles_for_members(query) end defp maybe_load_groups(query, false), do: query diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 53dab1a4..16977816 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -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 diff --git a/lib/mv_web/live/member_live/index/cookie.ex b/lib/mv_web/live/member_live/index/cookie.ex new file mode 100644 index 00000000..a3c2de88 --- /dev/null +++ b/lib/mv_web/live/member_live/index/cookie.ex @@ -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 diff --git a/lib/mv_web/live/member_live/index/export_payload.ex b/lib/mv_web/live/member_live/index/export_payload.ex new file mode 100644 index 00000000..3c15c611 --- /dev/null +++ b/lib/mv_web/live/member_live/index/export_payload.ex @@ -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 diff --git a/lib/mv_web/live/member_live/index/field_selection.ex b/lib/mv_web/live/member_live/index/field_selection.ex index 846cf1dc..fecbb2a8 100644 --- a/lib/mv_web/live/member_live/index/field_selection.ex +++ b/lib/mv_web/live/member_live/index/field_selection.ex @@ -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. diff --git a/lib/mv_web/live/member_live/index/field_visibility.ex b/lib/mv_web/live/member_live/index/field_visibility.ex index 682b7b9f..4cd28c76 100644 --- a/lib/mv_web/live/member_live/index/field_visibility.ex +++ b/lib/mv_web/live/member_live/index/field_visibility.ex @@ -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. diff --git a/lib/mv_web/member_live/index/membership_fee_status.ex b/lib/mv_web/live/member_live/index/membership_fee_status.ex similarity index 81% rename from lib/mv_web/member_live/index/membership_fee_status.ex rename to lib/mv_web/live/member_live/index/membership_fee_status.ex index d586b285..54571c49 100644 --- a/lib/mv_web/member_live/index/membership_fee_status.ex +++ b/lib/mv_web/live/member_live/index/membership_fee_status.ex @@ -21,8 +21,6 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do ## Parameters - `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 @@ -33,8 +31,8 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do 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(), boolean(), Date.t() | nil) :: Ash.Query.t() - def load_cycles_for_members(query, _show_current \\ false, _today \\ nil) do + @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]]) @@ -158,30 +156,6 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do 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 defp format_status_label(:paid), do: gettext("Paid") defp format_status_label(:unpaid), do: gettext("Unpaid") diff --git a/lib/mv_web/live/member_live/index/view_settings.ex b/lib/mv_web/live/member_live/index/view_settings.ex index d80a076e..b26fcb17 100644 --- a/lib/mv_web/live/member_live/index/view_settings.ex +++ b/lib/mv_web/live/member_live/index/view_settings.ex @@ -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 diff --git a/test/mv_web/live/member_live/index/cookie_test.exs b/test/mv_web/live/member_live/index/cookie_test.exs new file mode 100644 index 00000000..83e4b1cc --- /dev/null +++ b/test/mv_web/live/member_live/index/cookie_test.exs @@ -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 diff --git a/test/mv_web/live/member_live/index/export_payload_test.exs b/test/mv_web/live/member_live/index/export_payload_test.exs new file mode 100644 index 00000000..932816e6 --- /dev/null +++ b/test/mv_web/live/member_live/index/export_payload_test.exs @@ -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 diff --git a/test/mv_web/live/member_live/index/field_visibility_test.exs b/test/mv_web/live/member_live/index/field_visibility_test.exs index 728c6cf5..0da46324 100644 --- a/test/mv_web/live/member_live/index/field_visibility_test.exs +++ b/test/mv_web/live/member_live/index/field_visibility_test.exs @@ -6,201 +6,6 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do 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 test "compact member + compact address: Name, E-Mail, Adresse lead" do 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)) - 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 test "returns only member fields that are visible" do selection = %{ diff --git a/test/mv_web/member_live/index/membership_fee_status_test.exs b/test/mv_web/live/member_live/index/membership_fee_status_test.exs similarity index 85% rename from test/mv_web/member_live/index/membership_fee_status_test.exs rename to test/mv_web/live/member_live/index/membership_fee_status_test.exs index 8412f617..bcd3275c 100644 --- a/test/mv_web/member_live/index/membership_fee_status_test.exs +++ b/test/mv_web/live/member_live/index/membership_fee_status_test.exs @@ -6,7 +6,6 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do import Mv.Fixtures, only: [create_fee_type: 1, create_cycle: 3] - alias Mv.Membership.Member alias MvWeb.MemberLive.Index.MembershipFeeStatus require Ash.Query @@ -26,35 +25,6 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do member 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 test "returns status of last completed cycle" do fee_type = create_fee_type(%{interval: :yearly}) @@ -339,27 +309,5 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do assert length(filtered) == 1 assert List.first(filtered).id == member2.id 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 diff --git a/test/mv_web/member_live/index_membership_fee_status_test.exs b/test/mv_web/member_live/index_membership_fee_status_test.exs index 2efe055d..5ee23afb 100644 --- a/test/mv_web/member_live/index_membership_fee_status_test.exs +++ b/test/mv_web/member_live/index_membership_fee_status_test.exs @@ -95,6 +95,9 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do html = render(view) # Should show suspended status (current cycle) 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 test "shows correct color coding for paid status", %{conn: conn} do diff --git a/test/mv_web/member_live/index_pagination_test.exs b/test/mv_web/member_live/index_pagination_test.exs index e21a5531..e10ffd45 100644 --- a/test/mv_web/member_live/index_pagination_test.exs +++ b/test/mv_web/member_live/index_pagination_test.exs @@ -8,10 +8,10 @@ defmodule MvWeb.MemberLive.IndexPaginationTest do import Phoenix.LiveViewTest - require Ash.Query - alias Mv.Helpers.SystemActor + require Ash.Query + # Matches the :overview default_limit / @page_limit in the LiveView. @page_limit 50 diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 14d76a82..fbff9f47 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -708,27 +708,6 @@ defmodule MvWeb.MemberLive.IndexTest do # Flash message should appear assert has_element?(view, "#flash-group") 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 "] - _ = conn - end end describe "copy_emails empty-recipient feedback" do @@ -1800,153 +1779,6 @@ defmodule MvWeb.MemberLive.IndexTest do |> Ash.load!(:custom_field_values, actor: actor) 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 test "apply_boolean_custom_field_filters filters members with true value and excludes false/without values", %{conn: _conn} do