From 5715a22b0cbe1491fd6a4c5262cd4de9880bb40e Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 23:53:45 +0100 Subject: [PATCH 01/11] feat(members): add membership_fee_type to overview pseudo fields Allow Fee Type as selectable column in member overview dropdown. --- lib/mv_web/live/member_live/index/field_visibility.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 6427d4c..7b54bba 100644 --- a/lib/mv_web/live/member_live/index/field_visibility.ex +++ b/lib/mv_web/live/member_live/index/field_visibility.ex @@ -28,8 +28,8 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do alias Mv.Membership.Helpers.VisibilityConfig # Single UI key for "Membership Fee Status"; only this appears in the dropdown. - # Groups is also a pseudo field (not a DB attribute, but displayed in the table). - @pseudo_member_fields [:membership_fee_status, :groups] + # Groups and membership_fee_type are also pseudo fields (not in member_fields(), displayed in the table). + @pseudo_member_fields [:membership_fee_status, :membership_fee_type, :groups] # Export/API may accept this as alias; must not appear in the UI options list. @export_only_alias :payment_status -- 2.47.2 From b7ef69813b67ed2084206fb6bd078a74ae617979 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 23:53:47 +0100 Subject: [PATCH 02/11] feat(members): add Fee Type label and gettext strings MemberFields.label(:membership_fee_type), DE: Beitragsart. --- lib/mv_web/translations/member_fields.ex | 1 + priv/gettext/de/LC_MESSAGES/default.po | 6 ++++++ priv/gettext/default.pot | 6 ++++++ priv/gettext/en/LC_MESSAGES/default.po | 6 ++++++ 4 files changed, 19 insertions(+) diff --git a/lib/mv_web/translations/member_fields.ex b/lib/mv_web/translations/member_fields.ex index c9b8cad..9e98215 100644 --- a/lib/mv_web/translations/member_fields.ex +++ b/lib/mv_web/translations/member_fields.ex @@ -29,6 +29,7 @@ defmodule MvWeb.Translations.MemberFields do def label(:postal_code), do: gettext("Postal Code") def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date") def label(:membership_fee_status), do: gettext("Membership Fee Status") + def label(:membership_fee_type), do: gettext("Fee Type") def label(:groups), do: gettext("Groups") # Fallback for unknown fields diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index c418dca..9825efd 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2914,3 +2914,9 @@ msgstr "Sep." #, elixir-autogen, elixir-format msgid "Required for Vereinfacht integration and cannot be disabled." msgstr "Für die Vereinfacht-Integration erforderlich und kann nicht deaktiviert werden." + +#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/translations/member_fields.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Fee Type" +msgstr "Beitragsart" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 2e7e480..98c2b91 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2914,3 +2914,9 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Required for Vereinfacht integration and cannot be disabled." msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/translations/member_fields.ex +#, elixir-autogen, elixir-format +msgid "Fee Type" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 3c53a7e..b69f3df 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2914,3 +2914,9 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Required for Vereinfacht integration and cannot be disabled." msgstr "Required for Vereinfacht integration and cannot be disabled." + +#: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/translations/member_fields.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Fee Type" +msgstr "Fee Type" -- 2.47.2 From 68ceaced0cfbf7e73786cb4844eea329bf0d6a82 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 23:53:50 +0100 Subject: [PATCH 03/11] feat(members): show and sort by Fee Type in member overview Load membership_fee_type when column visible; sort by membership_fee_type_id; add table column with SortHeader and fee type name. --- lib/mv_web/live/member_live/index.ex | 89 ++++++++++++++++----- lib/mv_web/live/member_live/index.html.heex | 22 +++++ 2 files changed, 89 insertions(+), 22 deletions(-) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 218fa6f..e7c0fd7 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -913,6 +913,14 @@ defmodule MvWeb.MemberLive.Index do query = Ash.Query.load(query, groups: [:id, :name, :slug]) + # Load membership_fee_type when the column is visible + query = + if :membership_fee_type in socket.assigns.member_fields_visible do + Ash.Query.load(query, membership_fee_type: [:id, :name]) + else + query + end + query = apply_search_filter(query, search_query) query = apply_group_filters(query, socket.assigns[:group_filters], socket.assigns[:groups]) @@ -1073,6 +1081,10 @@ defmodule MvWeb.MemberLive.Index do field in [:groups, "groups"] -> {query, true} + # Membership fee type sort -> by FK at DB + field in [:membership_fee_type, "membership_fee_type"] -> + {Ash.Query.sort(query, membership_fee_type_id: order), false} + # Custom field sort -> after load custom_field_sort?(field) -> {query, true} @@ -1118,11 +1130,16 @@ defmodule MvWeb.MemberLive.Index do defp valid_sort_field_db_or_custom?(field) when is_atom(field) do non_sortable_fields = [:notes] valid_fields = Mv.Constants.member_fields() -- non_sortable_fields - field in valid_fields or custom_field_sort?(field) or field == :groups + field in valid_fields or custom_field_sort?(field) or field in [:groups, :membership_fee_type] end defp valid_sort_field_db_or_custom?(field) when is_binary(field) do - normalized = if field == "groups", do: :groups, else: safe_member_field_atom_only(field) + normalized = + cond do + field == "groups" -> :groups + field == "membership_fee_type" -> :membership_fee_type + true -> safe_member_field_atom_only(field) + end (normalized != nil and valid_sort_field_db_or_custom?(normalized)) or custom_field_sort?(field) @@ -1647,13 +1664,11 @@ defmodule MvWeb.MemberLive.Index do FieldVisibility.computed_member_fields() |> Enum.filter(&(&1 in member_fields_computed)) - # Include groups in export only if it's visible in the table member_fields_with_groups = - if :groups in socket.assigns[:member_fields_visible] do - ordered_member_fields_db ++ ["groups"] - else - ordered_member_fields_db - end + 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 = @@ -1674,7 +1689,9 @@ defmodule MvWeb.MemberLive.Index do export_column_order( ordered_member_fields_db, ordered_computed_fields, - ordered_custom_field_ids + 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]), @@ -1685,6 +1702,32 @@ defmodule MvWeb.MemberLive.Index do } 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 :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" @@ -1700,31 +1743,33 @@ defmodule MvWeb.MemberLive.Index do 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) - # - computed fields inserted at the correct position (membership_fee_status after membership_fee_start_date) + # - 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 + 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_status right after membership_fee_start_date if present in export - db_with_computed = - Enum.flat_map(db_strings, fn f -> - if f == "membership_fee_start_date" and "membership_fee_status" in computed_strings do - [f, "membership_fee_status"] - else - [f] - end - end) + # 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) + ) # Any remaining computed fields not inserted above (future-proof) remaining_computed = computed_strings - |> Enum.reject(&(&1 in db_with_computed)) + |> Enum.reject(&(&1 in db_with_extras)) - db_with_computed ++ remaining_computed ++ ordered_custom_field_ids + 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.html.heex b/lib/mv_web/live/member_live/index.html.heex index fa0f43a..fcf06c8 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -313,6 +313,28 @@ > {MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)} + <:col + :let={member} + :if={:membership_fee_type in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_membership_fee_type} + field={:membership_fee_type} + label={gettext("Fee Type")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + <%= if member.membership_fee_type do %> + {member.membership_fee_type.name} + <% else %> + + <% end %> + <:col :let={member} :if={:membership_fee_status in @member_fields_visible} -- 2.47.2 From f3b213ececa7eb3471fb1f7275a5681f2ae1e044 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 23:53:51 +0100 Subject: [PATCH 04/11] feat(export): include Fee Type in CSV export Payload and column_order when visible; allowlist, load, sort; MembersCSV cell for :membership_fee_type. --- lib/mv/membership/members_csv.ex | 7 ++ .../controllers/member_export_controller.ex | 67 ++++++++++++++++--- 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/lib/mv/membership/members_csv.ex b/lib/mv/membership/members_csv.ex index a47af8d..3d1fdd8 100644 --- a/lib/mv/membership/members_csv.ex +++ b/lib/mv/membership/members_csv.ex @@ -59,6 +59,13 @@ defmodule Mv.Membership.MembersCSV do if is_binary(value), do: value, else: "" end + defp cell_value(member, %{kind: :membership_fee_type, key: :membership_fee_type}) do + case Map.get(member, :membership_fee_type) do + %{name: name} when is_binary(name) -> name + _ -> "" + end + end + defp cell_value(member, %{kind: :groups, key: :groups}) do groups = Map.get(member, :groups) || [] format_groups(groups) diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex index 08bcba7..b5386a9 100644 --- a/lib/mv_web/controllers/member_export_controller.ex +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -19,7 +19,7 @@ defmodule MvWeb.MemberExportController do use Gettext, backend: MvWeb.Gettext @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ - ["groups"] + ["membership_fee_type", "groups"] @computed_export_fields ["membership_fee_status"] @custom_field_prefix Mv.Constants.custom_field_prefix() @@ -238,6 +238,7 @@ defmodule MvWeb.MemberExportController do parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields need_groups = "groups" in parsed.member_fields + need_membership_fee_type = "membership_fee_type" in parsed.member_fields query = Member @@ -246,6 +247,7 @@ defmodule MvWeb.MemberExportController do |> load_custom_field_values_query(parsed.custom_field_ids) |> maybe_load_cycles(need_cycles, parsed.show_current_cycle) |> maybe_load_groups(need_groups) + |> maybe_load_membership_fee_type(need_membership_fee_type) query = if parsed.selected_ids != [] do @@ -296,6 +298,12 @@ defmodule MvWeb.MemberExportController do Ash.Query.load(query, groups: [:id, :name]) end + defp maybe_load_membership_fee_type(query, false), do: query + + defp maybe_load_membership_fee_type(query, true) do + Ash.Query.load(query, membership_fee_type: [:id, :name]) + end + # Adds computed field values to members (e.g. membership_fee_status) defp add_computed_fields(members, computed_fields, show_current_cycle) do if "membership_fee_status" in computed_fields do @@ -343,26 +351,45 @@ defmodule MvWeb.MemberExportController do defp maybe_sort_export(query, field, order) when is_binary(field) do cond do field == "groups" -> - # Groups sort → in-memory nach dem Read (wie Tabelle) {query, true} + field == "membership_fee_type" -> + apply_membership_fee_type_sort_export(query, order) + custom_field_sort?(field) -> - # Custom field sort → in-memory nach dem Read (wie Tabelle) {query, true} true -> - field_atom = String.to_existing_atom(field) - - if field_atom in (Mv.Constants.member_fields() -- [:notes]) do - {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false} - else - {query, false} - end + apply_member_field_sort_export(query, field, order) end rescue ArgumentError -> {query, false} end + defp apply_membership_fee_type_sort_export(query, order) do + order_atom = if order == "desc", do: :desc, else: :asc + {Ash.Query.sort(query, membership_fee_type_id: order_atom), false} + end + + defp apply_member_field_sort_export(query, field, order) do + field_atom = String.to_existing_atom(field) + + sortable = + field_atom in (Mv.Constants.member_fields() -- [:notes]) or + field_atom == :membership_fee_type + + if sortable do + order_atom = if order == "desc", do: :desc, else: :asc + + sort_field = + if field_atom == :membership_fee_type, do: :membership_fee_type_id, else: field_atom + + {Ash.Query.sort(query, [{sort_field, order_atom}]), false} + else + {query, false} + end + end + defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix) # ------------------------------------------------------------------ @@ -488,6 +515,19 @@ defmodule MvWeb.MemberExportController do } end) + membership_fee_type_col = + if "membership_fee_type" in parsed.member_fields do + [ + %{ + header: membership_fee_type_field_header(conn), + kind: :membership_fee_type, + key: :membership_fee_type + } + ] + else + [] + end + groups_col = if "groups" in parsed.member_fields do [ @@ -519,7 +559,8 @@ defmodule MvWeb.MemberExportController do end) |> Enum.reject(&is_nil/1) - member_cols ++ computed_cols ++ groups_col ++ custom_cols + # Table order: ... membership_fee_start_date, membership_fee_type, membership_fee_status, groups, custom + member_cols ++ membership_fee_type_col ++ computed_cols ++ groups_col ++ custom_cols end # --- headers: use MemberFields.label for translations --- @@ -559,6 +600,10 @@ defmodule MvWeb.MemberExportController do cf.name end + defp membership_fee_type_field_header(_conn) do + MemberFields.label(:membership_fee_type) + end + defp groups_field_header(_conn) do MemberFields.label(:groups) end -- 2.47.2 From 8db24405fa121ad4673c3e104b9969391b5d22a5 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 23:53:54 +0100 Subject: [PATCH 05/11] test: fee type column visibility, CSV export, export controller FieldVisibility pseudo fields and visible selection; MembersCSV fee type column; export accepts membership_fee_type and returns Fee Type column. --- test/mv/membership/members_csv_test.exs | 37 +++++++++++++++++++ .../member_export_controller_test.exs | 31 ++++++++++++++++ .../index/field_visibility_test.exs | 22 +++++++++++ 3 files changed, 90 insertions(+) diff --git a/test/mv/membership/members_csv_test.exs b/test/mv/membership/members_csv_test.exs index 6b0a300..7a4dfa3 100644 --- a/test/mv/membership/members_csv_test.exs +++ b/test/mv/membership/members_csv_test.exs @@ -199,6 +199,43 @@ defmodule Mv.Membership.MembersCSVTest do assert csv =~ "M,m@m.com,Paid" end + test "membership_fee_type column exports fee type name" do + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Email", kind: :member_field, key: "email"}, + %{header: "Fee Type", kind: :membership_fee_type, key: :membership_fee_type} + ] + + member = %{ + first_name: "M", + email: "m@m.com", + membership_fee_type: %{id: "ft-1", name: "Standard"} + } + + iodata = MembersCSV.export([member], columns) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "Fee Type" + assert csv =~ "Standard" + assert csv =~ "M,m@m.com,Standard" + end + + test "membership_fee_type column exports empty when no fee type" do + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Fee Type", kind: :membership_fee_type, key: :membership_fee_type} + ] + + member = %{first_name: "M", email: "m@m.com", membership_fee_type: nil} + + iodata = MembersCSV.export([member], columns) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "Fee Type" + assert csv =~ "M," + refute csv =~ "Standard" + end + test "CSV injection: formula-like and dangerous prefixes are escaped with apostrophe" do member = %{ first_name: "=SUM(A1:A10)", diff --git a/test/mv_web/controllers/member_export_controller_test.exs b/test/mv_web/controllers/member_export_controller_test.exs index b7fff60..de7b417 100644 --- a/test/mv_web/controllers/member_export_controller_test.exs +++ b/test/mv_web/controllers/member_export_controller_test.exs @@ -146,6 +146,37 @@ defmodule MvWeb.MemberExportControllerTest do refute header =~ "unknown_field" end + test "export includes membership_fee_type column when requested", %{ + conn: conn, + member1: m1 + } do + payload = %{ + "selected_ids" => [m1.id], + "member_fields" => ["first_name", "membership_fee_type", "email"], + "custom_field_ids" => [], + "query" => nil, + "sort_field" => nil, + "sort_order" => nil + } + + conn = get(conn, "/members") + csrf_token = csrf_token_from_conn(conn) + + conn = + post(conn, "/members/export.csv", %{ + "payload" => Jason.encode!(payload), + "_csrf_token" => csrf_token + }) + + assert conn.status == 200 + body = response(conn, 200) + header = body |> export_lines() |> hd() + + # Fee Type column is included (label from MemberFields.label(:membership_fee_type)) + assert header =~ "Fee Type" + assert body =~ "Alice" + end + test "export includes membership_fee_status computed field when requested", %{ conn: conn, member1: m1 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 83ae06d..d86893d 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 @@ -56,6 +56,14 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do 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 @@ -278,6 +286,20 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do test "handles invalid input" do assert FieldVisibility.get_visible_member_fields(nil) == [] end + + test "returns membership_fee_type when visible in selection" do + selection = %{ + "first_name" => true, + "membership_fee_type" => true, + "groups" => false + } + + result = FieldVisibility.get_visible_member_fields(selection) + + assert :membership_fee_type in result + assert :first_name in result + refute :groups in result + end end describe "get_visible_custom_fields/1" do -- 2.47.2 From e86c78a0dc4b7753f86b306659c4b142a3b113fb Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 24 Feb 2026 00:20:20 +0100 Subject: [PATCH 06/11] feat(export): include Fee Type and groups in PDF export MemberExport allowlist and insert_fee_type; Build load/sort/cell_value; MemberPdfExportController allow membership_fee_type and groups. --- lib/mv/membership/member_export.ex | 47 ++++++---- lib/mv/membership/member_export/build.ex | 85 +++++++++++++++---- .../member_pdf_export_controller.ex | 3 +- 3 files changed, 101 insertions(+), 34 deletions(-) diff --git a/lib/mv/membership/member_export.ex b/lib/mv/membership/member_export.ex index b4272b0..a017480 100644 --- a/lib/mv/membership/member_export.ex +++ b/lib/mv/membership/member_export.ex @@ -16,7 +16,7 @@ defmodule Mv.Membership.MemberExport do alias MvWeb.MemberLive.Index.MembershipFeeStatus @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ - ["membership_fee_status", "groups"] + ["membership_fee_type", "membership_fee_status", "groups"] @computed_export_fields ["membership_fee_status"] @computed_insert_after "membership_fee_start_date" @custom_field_prefix Mv.Constants.custom_field_prefix() @@ -326,10 +326,10 @@ defmodule Mv.Membership.MemberExport do # Separate groups from other fields (groups is handled as a special field, not a member field) groups_field = if "groups" in member_fields, do: ["groups"], else: [] - # final member_fields list (used for column specs order): table order + computed inserted + groups + # final member_fields list (used for column specs order): table order + fee type + computed + groups ordered_member_fields = selectable_member_fields - |> insert_computed_fields_like_table(computed_fields) + |> insert_fee_type_and_computed_fields_like_table(computed_fields, member_fields) |> then(fn fields -> fields ++ groups_field end) %{ @@ -420,27 +420,44 @@ defmodule Mv.Membership.MemberExport do table_order |> Enum.filter(&(&1 in fields)) end - defp insert_computed_fields_like_table(db_fields_ordered, computed_fields) do - # Insert membership_fee_status right after membership_fee_start_date (if both selected), - # otherwise append at the end of DB fields. + defp insert_fee_type_and_computed_fields_like_table( + db_fields_ordered, + computed_fields, + member_fields + ) do computed_fields = computed_fields || [] + member_fields = member_fields || [] db_with_insert = Enum.flat_map(db_fields_ordered, fn f -> - if f == @computed_insert_after and "membership_fee_status" in computed_fields do - [f, "membership_fee_status"] - else - [f] - end + expand_field_with_computed(f, member_fields, computed_fields) end) - remaining = - computed_fields - |> Enum.reject(&(&1 in db_with_insert)) - + remaining = Enum.reject(computed_fields, &(&1 in db_with_insert)) db_with_insert ++ remaining end + # Insert membership_fee_type and membership_fee_status after membership_fee_start_date (table order). + defp expand_field_with_computed(f, member_fields, computed_fields) do + if f == @computed_insert_after do + extra = [] + + extra = + if "membership_fee_type" in member_fields, + do: extra ++ ["membership_fee_type"], + else: extra + + extra = + if "membership_fee_status" in computed_fields, + do: extra ++ ["membership_fee_status"], + else: extra + + [f] ++ extra + else + [f] + end + end + defp normalize_computed_fields(fields) when is_list(fields) do fields |> Enum.filter(&is_binary/1) diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex index 9e0cc7b..8a5aa60 100644 --- a/lib/mv/membership/member_export/build.ex +++ b/lib/mv/membership/member_export/build.ex @@ -133,6 +133,7 @@ defmodule Mv.Membership.MemberExport.Build do "membership_fee_status" in parsed.member_fields need_groups = "groups" in parsed.member_fields + need_membership_fee_type = "membership_fee_type" in parsed.member_fields query = Member @@ -141,6 +142,7 @@ defmodule Mv.Membership.MemberExport.Build do |> load_custom_field_values_query(custom_field_ids_union) |> maybe_load_cycles(need_cycles, parsed.show_current_cycle) |> maybe_load_groups(need_groups) + |> maybe_load_membership_fee_type(need_membership_fee_type) query = if parsed.selected_ids != [] do @@ -196,8 +198,11 @@ defmodule Mv.Membership.MemberExport.Build do defp sort_members_in_memory(members, field, order) when is_binary(field) do field_atom = String.to_existing_atom(field) - if field_atom in Mv.Constants.member_fields() do - sort_by_field(members, field_atom, order) + if field_atom in Mv.Constants.member_fields() or field_atom == :membership_fee_type do + sort_field = + if field_atom == :membership_fee_type, do: :membership_fee_type_id, else: field_atom + + sort_by_field(members, sort_field, order) else members end @@ -245,26 +250,39 @@ defmodule Mv.Membership.MemberExport.Build do defp maybe_sort(query, field, order) when is_binary(field) do cond do - field == "groups" -> - # Groups sort → in-memory nach dem Read (wie Tabelle) - {query, true} - - custom_field_sort?(field) -> - {query, true} - - true -> - field_atom = String.to_existing_atom(field) - - if field_atom in (Mv.Constants.member_fields() -- [:notes]) do - {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false} - else - {query, false} - end + field == "groups" -> {query, true} + field == "membership_fee_type" -> apply_fee_type_sort(query, order) + custom_field_sort?(field) -> {query, true} + true -> apply_standard_member_sort(query, field, order) end rescue ArgumentError -> {query, false} end + defp apply_fee_type_sort(query, order) do + order_atom = if order == "desc", do: :desc, else: :asc + {Ash.Query.sort(query, membership_fee_type_id: order_atom), false} + end + + defp apply_standard_member_sort(query, field, order) do + field_atom = String.to_existing_atom(field) + + sortable = + field_atom in (Mv.Constants.member_fields() -- [:notes]) or + field_atom == :membership_fee_type + + if sortable do + order_atom = if order == "desc", do: :desc, else: :asc + + sort_field = + if field_atom == :membership_fee_type, do: :membership_fee_type_id, else: field_atom + + {Ash.Query.sort(query, [{sort_field, order_atom}]), false} + else + {query, false} + end + end + defp sort_members_by_custom_field(members, _field, _order, _custom_fields) when members == [], do: [] @@ -344,6 +362,12 @@ defmodule Mv.Membership.MemberExport.Build do Ash.Query.load(query, groups: [:id, :name]) end + defp maybe_load_membership_fee_type(query, false), do: query + + defp maybe_load_membership_fee_type(query, true) do + Ash.Query.load(query, membership_fee_type: [:id, :name]) + end + defp apply_cycle_status_filter(members, nil, _show_current), do: members defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do @@ -393,6 +417,19 @@ defmodule Mv.Membership.MemberExport.Build do } end) + membership_fee_type_col = + if "membership_fee_type" in parsed.member_fields do + [ + %{ + key: :membership_fee_type, + kind: :membership_fee_type, + label: label_fn.(:membership_fee_type) + } + ] + else + [] + end + groups_col = if "groups" in parsed.member_fields do [ @@ -424,7 +461,8 @@ defmodule Mv.Membership.MemberExport.Build do end) |> Enum.reject(&is_nil/1) - member_cols ++ computed_cols ++ groups_col ++ custom_cols + # Table order: ... membership_fee_start_date, membership_fee_type, membership_fee_status, groups, custom + member_cols ++ membership_fee_type_col ++ computed_cols ++ groups_col ++ custom_cols end defp build_rows(members, columns, custom_fields_by_id) do @@ -454,6 +492,17 @@ defmodule Mv.Membership.MemberExport.Build do if is_binary(value), do: value, else: "" end + defp cell_value( + member, + %{kind: :membership_fee_type, key: :membership_fee_type}, + _custom_fields_by_id + ) do + case Map.get(member, :membership_fee_type) do + %{name: name} when is_binary(name) -> name + _ -> "" + end + end + defp cell_value(member, %{kind: :groups, key: :groups}, _custom_fields_by_id) do groups = Map.get(member, :groups) || [] format_groups(groups) diff --git a/lib/mv_web/controllers/member_pdf_export_controller.ex b/lib/mv_web/controllers/member_pdf_export_controller.ex index 63feef2..f00c0d1 100644 --- a/lib/mv_web/controllers/member_pdf_export_controller.ex +++ b/lib/mv_web/controllers/member_pdf_export_controller.ex @@ -20,7 +20,8 @@ defmodule MvWeb.MemberPdfExportController do @invalid_json_message "invalid JSON" @export_failed_message "Failed to generate PDF export" - @allowed_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + @allowed_member_field_strings (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ + ["membership_fee_type", "groups"] def export(conn, %{"payload" => payload}) when is_binary(payload) do actor = current_actor(conn) -- 2.47.2 From d41d13d1220d659545d80a2b3052ebe380c920c8 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 24 Feb 2026 01:06:41 +0100 Subject: [PATCH 07/11] fix(members): restore column visibility from URL on reload Read 'fields' from URI when conn.params has no query (e.g. full page load). When ?fields=... is present use URL-only selection so columns are not merged with global settings. Fall back to session+global when URL has only invalid field names. --- lib/mv_web/live/member_live/index.ex | 82 +++++++++++++++---- .../member_live/index/field_visibility.ex | 19 +++++ 2 files changed, 86 insertions(+), 15 deletions(-) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index e7c0fd7..889a818 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -615,7 +615,9 @@ defmodule MvWeb.MemberLive.Index do # ----------------------------------------------------------------- @impl true - def handle_params(params, _url, socket) do + def handle_params(params, url, socket) do + url = url || request_url_from_socket(socket) + params = merge_fields_param_from_uri(params, url) prev_sig = build_signature(socket) fields_in_url? = @@ -625,20 +627,7 @@ defmodule MvWeb.MemberLive.Index do end url_selection = FieldSelection.parse_from_url(params) - - merged_selection = - FieldSelection.merge_sources( - url_selection, - socket.assigns.user_field_selection, - %{} - ) - - final_selection = - FieldVisibility.merge_with_global_settings( - merged_selection, - socket.assigns.settings, - socket.assigns.all_custom_fields - ) + final_selection = compute_final_field_selection(fields_in_url?, url_selection, socket) visible_member_fields = final_selection @@ -828,6 +817,69 @@ defmodule MvWeb.MemberLive.Index do add_boolean_filters(base_params, boolean_filters) end + defp compute_final_field_selection(true, url_selection, socket) do + only_url = + FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields) + + visible = FieldVisibility.get_visible_member_fields(only_url) + + if visible == [] do + # URL had only invalid field names; fall back to session + global. + compute_final_field_selection(false, url_selection, socket) + else + only_url + end + end + + defp compute_final_field_selection(false, url_selection, socket) do + merged = + FieldSelection.merge_sources( + url_selection, + socket.assigns.user_field_selection, + %{} + ) + + FieldVisibility.merge_with_global_settings( + merged, + socket.assigns.settings, + socket.assigns.all_custom_fields + ) + end + + # On full page load conn.params has no query string; read "fields" from URI so column visibility is restored. + defp request_url_from_socket(socket) do + case socket.private[:connect_info] do + %Plug.Conn{} = conn -> Plug.Conn.request_url(conn) + _ -> nil + end + end + + defp merge_fields_param_from_uri(params, nil), do: params + + defp merge_fields_param_from_uri(params, %URI{query: query}) when is_binary(query) do + case URI.decode_query(query)["fields"] do + nil -> params + value -> Map.put(params, "fields", value) + end + end + + defp merge_fields_param_from_uri(params, %URI{}), do: params + + defp merge_fields_param_from_uri(params, url) when is_binary(url) do + case URI.parse(url).query do + nil -> + params + + q -> + case URI.decode_query(q)["fields"] do + nil -> params + value -> Map.put(params, "fields", value) + end + end + end + + defp merge_fields_param_from_uri(params, _), do: params + defp build_base_params(query, sort_field, sort_order) do %{ "query" => query || "", 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 7b54bba..df20d25 100644 --- a/lib/mv_web/live/member_live/index/field_visibility.ex +++ b/lib/mv_web/live/member_live/index/field_visibility.ex @@ -64,6 +64,25 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do 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 + all_fields = get_all_available_fields(custom_fields) + + Enum.reduce(all_fields, %{}, fn field, acc -> + field_string = field_to_string(field) + visible = Map.get(url_selection, field_string, false) + Map.put(acc, field_string, visible) + end) + end + + def selection_from_url_only(_, _), do: %{} + @doc """ Merges user field selection with global settings. -- 2.47.2 From 94bcb5dc8c37ba290d545c0897c6ffb65a0fd366 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 24 Feb 2026 09:15:04 +0100 Subject: [PATCH 08/11] fix: sort Fee Type by name in LiveView and exports Use Ash related-field sort (membership_fee_type.name) instead of membership_fee_type_id so column order is alphabetical. Load membership_fee_type when sorting by it even if column is hidden. In-memory re-sort (Build) uses loaded fee type name. --- lib/mv/membership/member_export/build.ex | 34 ++++++++++++------- .../controllers/member_export_controller.ex | 7 ++-- lib/mv_web/live/member_live/index.ex | 26 +++++++++++--- 3 files changed, 48 insertions(+), 19 deletions(-) diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex index 8a5aa60..9a1c03a 100644 --- a/lib/mv/membership/member_export/build.ex +++ b/lib/mv/membership/member_export/build.ex @@ -133,7 +133,10 @@ defmodule Mv.Membership.MemberExport.Build do "membership_fee_status" in parsed.member_fields need_groups = "groups" in parsed.member_fields - need_membership_fee_type = "membership_fee_type" in parsed.member_fields + + need_membership_fee_type = + "membership_fee_type" in parsed.member_fields or + parsed.sort_field == "membership_fee_type" query = Member @@ -199,10 +202,9 @@ defmodule Mv.Membership.MemberExport.Build do field_atom = String.to_existing_atom(field) if field_atom in Mv.Constants.member_fields() or field_atom == :membership_fee_type do - sort_field = - if field_atom == :membership_fee_type, do: :membership_fee_type_id, else: field_atom - - sort_by_field(members, sort_field, order) + key_fn = sort_key_fn_for_field(field_atom) + compare_fn = build_compare_fn(order) + Enum.sort_by(members, key_fn, compare_fn) else members end @@ -212,13 +214,17 @@ defmodule Mv.Membership.MemberExport.Build do defp sort_members_in_memory(members, _field, _order), do: members - defp sort_by_field(members, field_atom, order) do - key_fn = fn member -> Map.get(member, field_atom) end - compare_fn = build_compare_fn(order) - - Enum.sort_by(members, key_fn, compare_fn) + defp sort_key_fn_for_field(:membership_fee_type) do + fn member -> + case Map.get(member, :membership_fee_type) do + nil -> nil + rel -> Map.get(rel, :name) + end + end end + defp sort_key_fn_for_field(field_atom), do: fn member -> Map.get(member, field_atom) end + defp build_compare_fn("asc"), do: fn a, b -> a <= b end defp build_compare_fn("desc"), do: fn a, b -> b <= a end defp build_compare_fn(_), do: fn _a, _b -> true end @@ -261,7 +267,7 @@ defmodule Mv.Membership.MemberExport.Build do defp apply_fee_type_sort(query, order) do order_atom = if order == "desc", do: :desc, else: :asc - {Ash.Query.sort(query, membership_fee_type_id: order_atom), false} + {Ash.Query.sort(query, [{"membership_fee_type.name", order_atom}]), false} end defp apply_standard_member_sort(query, field, order) do @@ -275,9 +281,11 @@ defmodule Mv.Membership.MemberExport.Build do order_atom = if order == "desc", do: :desc, else: :asc sort_field = - if field_atom == :membership_fee_type, do: :membership_fee_type_id, else: field_atom + if field_atom == :membership_fee_type, + do: {"membership_fee_type.name", order_atom}, + else: {field_atom, order_atom} - {Ash.Query.sort(query, [{sort_field, order_atom}]), false} + {Ash.Query.sort(query, [sort_field]), false} else {query, false} end diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex index b5386a9..715f86a 100644 --- a/lib/mv_web/controllers/member_export_controller.ex +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -238,7 +238,10 @@ defmodule MvWeb.MemberExportController do parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields need_groups = "groups" in parsed.member_fields - need_membership_fee_type = "membership_fee_type" in parsed.member_fields + + need_membership_fee_type = + "membership_fee_type" in parsed.member_fields or + parsed.sort_field == "membership_fee_type" query = Member @@ -368,7 +371,7 @@ defmodule MvWeb.MemberExportController do defp apply_membership_fee_type_sort_export(query, order) do order_atom = if order == "desc", do: :desc, else: :asc - {Ash.Query.sort(query, membership_fee_type_id: order_atom), false} + {Ash.Query.sort(query, [{"membership_fee_type.name", order_atom}]), false} end defp apply_member_field_sort_export(query, field, order) do diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 889a818..8fb50b9 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -965,9 +965,10 @@ defmodule MvWeb.MemberLive.Index do query = Ash.Query.load(query, groups: [:id, :name, :slug]) - # Load membership_fee_type when the column is visible + # Load membership_fee_type when the column is visible or when sorting by it query = - if :membership_fee_type in socket.assigns.member_fields_visible do + if :membership_fee_type in socket.assigns.member_fields_visible or + socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do Ash.Query.load(query, membership_fee_type: [:id, :name]) else query @@ -1133,9 +1134,9 @@ defmodule MvWeb.MemberLive.Index do field in [:groups, "groups"] -> {query, true} - # Membership fee type sort -> by FK at DB + # Membership fee type sort -> by related name at DB field in [:membership_fee_type, "membership_fee_type"] -> - {Ash.Query.sort(query, membership_fee_type_id: order), false} + {Ash.Query.sort(query, [{"membership_fee_type.name", order}]), false} # Custom field sort -> after load custom_field_sort?(field) -> @@ -1777,6 +1778,15 @@ defmodule MvWeb.MemberLive.Index do 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 @@ -1815,6 +1825,14 @@ defmodule MvWeb.MemberLive.Index do &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 -- 2.47.2 From 1c8c5ae83bb143bab566663e5662799fb33a5449 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 24 Feb 2026 09:15:08 +0100 Subject: [PATCH 09/11] fix: include Fee Type in export when Start Date not in fields Append membership_fee_type to column list when it is visible but membership_fee_start_date was not in the selection (MemberExport, export_column_order, build_export_member_fields_list). --- lib/mv/membership/member_export.ex | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/mv/membership/member_export.ex b/lib/mv/membership/member_export.ex index a017480..bbfbb6e 100644 --- a/lib/mv/membership/member_export.ex +++ b/lib/mv/membership/member_export.ex @@ -433,6 +433,14 @@ defmodule Mv.Membership.MemberExport do expand_field_with_computed(f, member_fields, computed_fields) end) + # If fee type is visible but start_date was not in the list, it won't be in db_with_insert + db_with_insert = + if "membership_fee_type" in member_fields and "membership_fee_type" not in db_with_insert do + db_with_insert ++ ["membership_fee_type"] + else + db_with_insert + end + remaining = Enum.reject(computed_fields, &(&1 in db_with_insert)) db_with_insert ++ remaining end -- 2.47.2 From d5df2338a7a91ec700039a56112acc3f40cd0b33 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 24 Feb 2026 09:15:41 +0100 Subject: [PATCH 10/11] test: export and PDF regression for Fee Type without start_date Add test for CSV export with only first_name and membership_fee_type. Add test for PDF export with same field set (status and content-type). --- priv/gettext/de/LC_MESSAGES/default.po | 2 +- priv/gettext/en/LC_MESSAGES/default.po | 2 +- .../member_export_controller_test.exs | 69 +++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 9825efd..2f4c1b8 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2917,6 +2917,6 @@ msgstr "Für die Vereinfacht-Integration erforderlich und kann nicht deaktiviert #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/translations/member_fields.ex -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "Fee Type" msgstr "Beitragsart" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index b69f3df..a76c9f6 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2917,6 +2917,6 @@ msgstr "Required for Vereinfacht integration and cannot be disabled." #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/translations/member_fields.ex -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "Fee Type" msgstr "Fee Type" diff --git a/test/mv_web/controllers/member_export_controller_test.exs b/test/mv_web/controllers/member_export_controller_test.exs index de7b417..cfc89ec 100644 --- a/test/mv_web/controllers/member_export_controller_test.exs +++ b/test/mv_web/controllers/member_export_controller_test.exs @@ -177,6 +177,39 @@ defmodule MvWeb.MemberExportControllerTest do assert body =~ "Alice" end + # Regression: when membership_fee_start_date is not in member_fields, Fee Type must still be exported (append fallback) + test "export includes Fee Type when only first_name and membership_fee_type are requested (no start_date)", + %{ + conn: conn, + member1: m1 + } do + payload = %{ + "selected_ids" => [m1.id], + "member_fields" => ["first_name", "membership_fee_type"], + "custom_field_ids" => [], + "query" => nil, + "sort_field" => nil, + "sort_order" => nil + } + + conn = get(conn, "/members") + csrf_token = csrf_token_from_conn(conn) + + conn = + post(conn, "/members/export.csv", %{ + "payload" => Jason.encode!(payload), + "_csrf_token" => csrf_token + }) + + assert conn.status == 200 + body = response(conn, 200) + header = body |> export_lines() |> hd() + + assert header =~ "Fee Type" + assert header =~ "First Name" + assert body =~ "Alice" + end + test "export includes membership_fee_status computed field when requested", %{ conn: conn, member1: m1 @@ -532,4 +565,40 @@ defmodule MvWeb.MemberExportControllerTest do assert membership_idx < active_idx end end + + describe "POST /members/export.pdf" do + test "PDF export includes Fee Type column when requested without membership_fee_start_date", + %{ + conn: conn + } do + m = + Fixtures.member_fixture(%{first_name: "PDF", last_name: "Test", email: "pdf@example.com"}) + + payload = %{ + "selected_ids" => [m.id], + "member_fields" => ["first_name", "membership_fee_type"], + "custom_field_ids" => [], + "query" => nil, + "sort_field" => nil, + "sort_order" => nil + } + + conn = get(conn, "/members") + csrf_token = csrf_token_from_conn(conn) + + conn = + post(conn, "/members/export.pdf", %{ + "payload" => Jason.encode!(payload), + "_csrf_token" => csrf_token + }) + + assert conn.status == 200 + assert get_resp_header(conn, "content-type") |> List.first() =~ "application/pdf" + + body = response(conn, 200) + + # PDF is generated successfully with Fee Type in columns (regression: fee type without start_date) + assert is_binary(body) and byte_size(body) > 0 + end + end end -- 2.47.2 From 10ad32eb6feb681deebeb8886de36afcdd4060ca Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 24 Feb 2026 09:42:10 +0100 Subject: [PATCH 11/11] fix: treat URL with only custom fields as valid in ?fields= mode Consider visible custom fields in compute_final_field_selection so that a link with only custom_field_X is not wrongly treated as invalid and reverted to session/global. Add test for URL containing only custom field. --- lib/mv_web/live/member_live/index.ex | 5 +++-- .../member_live/index_field_visibility_test.exs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 8fb50b9..3283b5c 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -821,9 +821,10 @@ defmodule MvWeb.MemberLive.Index do only_url = FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields) - visible = FieldVisibility.get_visible_member_fields(only_url) + visible_members = FieldVisibility.get_visible_member_fields(only_url) + visible_custom = FieldVisibility.get_visible_custom_fields(only_url) - if visible == [] do + if visible_members == [] and visible_custom == [] do # URL had only invalid field names; fall back to session + global. compute_final_field_selection(false, url_selection, socket) else diff --git a/test/mv_web/member_live/index_field_visibility_test.exs b/test/mv_web/member_live/index_field_visibility_test.exs index 8de9c7e..79d078b 100644 --- a/test/mv_web/member_live/index_field_visibility_test.exs +++ b/test/mv_web/member_live/index_field_visibility_test.exs @@ -361,6 +361,18 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do assert html =~ "Alice" end + test "URL with only custom field keeps custom field visible (no invalid fallback)", %{ + conn: conn, + custom_field: custom_field + } do + conn = conn_with_oidc_user(conn) + id = custom_field.id + {:ok, _view, html} = live(conn, "/members?fields=custom_field_#{id}") + + # Selection must not be treated as invalid; custom field column stays visible + assert html =~ "M001" or html =~ custom_field.name + end + test "handles rapid toggling", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") -- 2.47.2