From 01f62297fce5197230744c661f8f36f9b1334b41 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 19 Feb 2026 14:36:35 +0100 Subject: [PATCH 1/4] feat: add groups to export --- lib/mv/membership/member_export.ex | 8 +++- lib/mv/membership/member_export/build.ex | 39 ++++++++++++++++++- lib/mv/membership/members_csv.ex | 14 +++++++ .../controllers/member_export_controller.ex | 33 +++++++++++++++- lib/mv_web/live/member_live/index.ex | 9 ++++- lib/mv_web/translations/member_fields.ex | 1 + 6 files changed, 98 insertions(+), 6 deletions(-) diff --git a/lib/mv/membership/member_export.ex b/lib/mv/membership/member_export.ex index e243d40..b4272b0 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"] + ["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() @@ -323,10 +323,14 @@ defmodule Mv.Membership.MemberExport do |> Enum.filter(&(&1 in @domain_member_field_strings)) |> order_member_fields_like_table() - # final member_fields list (used for column specs order): table order + computed inserted + # 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 ordered_member_fields = selectable_member_fields |> insert_computed_fields_like_table(computed_fields) + |> then(fn fields -> fields ++ groups_field end) %{ selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")), diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex index ce1e98c..323681d 100644 --- a/lib/mv/membership/member_export/build.ex +++ b/lib/mv/membership/member_export/build.ex @@ -132,12 +132,15 @@ defmodule Mv.Membership.MemberExport.Build do parsed.computed_fields != [] or "membership_fee_status" in parsed.member_fields + need_groups = "groups" in parsed.member_fields + query = Member |> 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_groups(need_groups) query = if parsed.selected_ids != [] do @@ -294,6 +297,13 @@ defmodule Mv.Membership.MemberExport.Build do MembershipFeeStatus.load_cycles_for_members(query, show_current) end + defp maybe_load_groups(query, false), do: query + + defp maybe_load_groups(query, true) do + # Load groups with id and name only (for export formatting) + Ash.Query.load(query, groups: [: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 @@ -343,6 +353,19 @@ defmodule Mv.Membership.MemberExport.Build do } end) + groups_col = + if "groups" in parsed.member_fields do + [ + %{ + key: :groups, + kind: :groups, + label: label_fn.(:groups) + } + ] + else + [] + end + custom_cols = parsed.custom_field_ids |> Enum.map(fn id -> @@ -361,7 +384,7 @@ defmodule Mv.Membership.MemberExport.Build do end) |> Enum.reject(&is_nil/1) - member_cols ++ computed_cols ++ custom_cols + member_cols ++ computed_cols ++ groups_col ++ custom_cols end defp build_rows(members, columns, custom_fields_by_id) do @@ -391,6 +414,11 @@ defmodule Mv.Membership.MemberExport.Build do if is_binary(value), do: value, else: "" end + defp cell_value(member, %{kind: :groups, key: :groups}, _custom_fields_by_id) do + groups = Map.get(member, :groups) || [] + format_groups(groups) + end + defp key_to_atom(k) when is_atom(k), do: k defp key_to_atom(k) when is_binary(k) do @@ -424,6 +452,15 @@ defmodule Mv.Membership.MemberExport.Build do defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt) defp format_member_value(value), do: to_string(value) + defp format_groups([]), do: "" + + defp format_groups(groups) when is_list(groups) do + groups + |> Enum.map(fn group -> Map.get(group, :name) || "" end) + |> Enum.reject(&(&1 == "")) + |> Enum.join(", ") + end + defp build_meta(members) do %{ generated_at: DateTime.utc_now() |> DateTime.to_iso8601(), diff --git a/lib/mv/membership/members_csv.ex b/lib/mv/membership/members_csv.ex index a0fd463..a47af8d 100644 --- a/lib/mv/membership/members_csv.ex +++ b/lib/mv/membership/members_csv.ex @@ -59,6 +59,11 @@ defmodule Mv.Membership.MembersCSV do if is_binary(value), do: value, else: "" end + defp cell_value(member, %{kind: :groups, key: :groups}) do + groups = Map.get(member, :groups) || [] + format_groups(groups) + end + defp key_to_atom(k) when is_atom(k), do: k defp key_to_atom(k) when is_binary(k) do @@ -97,4 +102,13 @@ defmodule Mv.Membership.MembersCSV do defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt) defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt) defp format_member_value(value), do: to_string(value) + + defp format_groups([]), do: "" + + defp format_groups(groups) when is_list(groups) do + groups + |> Enum.map(fn group -> Map.get(group, :name) || "" end) + |> Enum.reject(&(&1 == "")) + |> Enum.join(", ") + end end diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex index 009a985..911e6d9 100644 --- a/lib/mv_web/controllers/member_export_controller.ex +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -18,7 +18,8 @@ defmodule MvWeb.MemberExportController do alias MvWeb.MemberLive.Index.MembershipFeeStatus use Gettext, backend: MvWeb.Gettext - @member_fields_allowlist Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ + ["groups"] @computed_export_fields ["membership_fee_status"] @custom_field_prefix Mv.Constants.custom_field_prefix() @@ -83,6 +84,7 @@ defmodule MvWeb.MemberExportController do domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) selectable = Enum.filter(member_fields, fn f -> f in domain_fields end) computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end) + # "groups" is neither a domain field nor a computed field, it's handled separately {selectable, computed} end @@ -235,12 +237,15 @@ defmodule MvWeb.MemberExportController do need_cycles = parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields + need_groups = "groups" in parsed.member_fields + query = Member |> Ash.Query.new() |> Ash.Query.select(select_fields) |> load_custom_field_values_query(parsed.custom_field_ids) |> maybe_load_cycles(need_cycles, parsed.show_current_cycle) + |> maybe_load_groups(need_groups) query = if parsed.selected_ids != [] do @@ -284,6 +289,13 @@ defmodule MvWeb.MemberExportController do MembershipFeeStatus.load_cycles_for_members(query, show_current) end + defp maybe_load_groups(query, false), do: query + + defp maybe_load_groups(query, true) do + # Load groups with id and name only (for export formatting) + Ash.Query.load(query, groups: [: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 @@ -441,6 +453,19 @@ defmodule MvWeb.MemberExportController do } end) + groups_col = + if "groups" in parsed.member_fields do + [ + %{ + header: groups_field_header(conn), + kind: :groups, + key: :groups + } + ] + else + [] + end + custom_cols = parsed.custom_field_ids |> Enum.map(fn id -> @@ -459,7 +484,7 @@ defmodule MvWeb.MemberExportController do end) |> Enum.reject(&is_nil/1) - member_cols ++ computed_cols ++ custom_cols + member_cols ++ computed_cols ++ groups_col ++ custom_cols end # --- headers: use MemberFields.label for translations --- @@ -499,6 +524,10 @@ defmodule MvWeb.MemberExportController do cf.name end + defp groups_field_header(_conn) do + MemberFields.label(:groups) + end + defp humanize_field(str) do str |> String.replace("_", " ") diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index d391cd2..eef871f 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -1620,6 +1620,10 @@ defmodule MvWeb.MemberLive.Index do FieldVisibility.computed_member_fields() |> Enum.filter(&(&1 in member_fields_computed)) + # Groups is always included in export if it's visible in the table + # (groups column is always shown in the table, so we always include it in export) + member_fields_with_groups = ordered_member_fields_db ++ ["groups"] + # Order custom fields like the table (same as dynamic_cols / all_custom_fields order) ordered_custom_field_ids = socket.assigns.all_custom_fields @@ -1628,7 +1632,10 @@ defmodule MvWeb.MemberLive.Index do %{ selected_ids: socket.assigns.selected_members |> MapSet.to_list(), - member_fields: Enum.map(ordered_member_fields_db, &Atom.to_string/1), + 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: diff --git a/lib/mv_web/translations/member_fields.ex b/lib/mv_web/translations/member_fields.ex index 83ab139..c9b8cad 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(:groups), do: gettext("Groups") # Fallback for unknown fields def label(field) do -- 2.47.2 From dbdac5870a0717710c8bdba35b9ced8bb898d157 Mon Sep 17 00:00:00 2001 From: carla Date: Fri, 20 Feb 2026 08:45:21 +0100 Subject: [PATCH 2/4] fix: adds shoe/hide for group column --- lib/mv_web/live/member_live/index.ex | 79 ++++++++++++++----- lib/mv_web/live/member_live/index.html.heex | 1 + .../member_live/index/field_visibility.ex | 5 +- priv/gettext/de/LC_MESSAGES/default.po | 1 + priv/gettext/default.pot | 1 + priv/gettext/en/LC_MESSAGES/default.po | 1 + 6 files changed, 66 insertions(+), 22 deletions(-) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index eef871f..526f858 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -682,6 +682,18 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns() end + # Update sort components after rendering + socket = + if socket.assigns[:sort_needs_update] do + old_field = socket.assigns[:previous_sort_field] || socket.assigns.sort_field + socket + |> update_sort_components(old_field, socket.assigns.sort_field, socket.assigns.sort_order) + |> assign(:sort_needs_update, false) + |> assign(:previous_sort_field, nil) + else + socket + end + {:noreply, socket} end @@ -940,9 +952,10 @@ defmodule MvWeb.MemberLive.Index do ) # Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked) + # Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status members = if sort_after_load and - socket.assigns.sort_field not in FieldVisibility.computed_member_fields() do + socket.assigns.sort_field != :membership_fee_status do sort_members_in_memory( members, socket.assigns.sort_field, @@ -1044,7 +1057,9 @@ defmodule MvWeb.MemberLive.Index do defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false} defp maybe_sort(query, field, order, _custom_fields) do - if computed_field?(field) do + # :groups is in computed_member_fields() but can be sorted in-memory + # Only :membership_fee_status should be blocked from sorting + if field == :membership_fee_status or field == "membership_fee_status" do {query, false} else apply_sort_to_query(query, field, order) @@ -1086,13 +1101,19 @@ defmodule MvWeb.MemberLive.Index do end defp valid_sort_field?(field) when is_atom(field) do - if field in FieldVisibility.computed_member_fields(), - do: false, - else: valid_sort_field_db_or_custom?(field) + # :groups is in computed_member_fields() but can be sorted + # Only :membership_fee_status should be blocked + if field == :membership_fee_status do + false + else + valid_sort_field_db_or_custom?(field) + end end defp valid_sort_field?(field) when is_binary(field) do - if field in Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) do + # "groups" is in computed_member_fields() but can be sorted + # Only "membership_fee_status" should be blocked + if field == "membership_fee_status" do false else valid_sort_field_db_or_custom?(field) @@ -1249,10 +1270,13 @@ defmodule MvWeb.MemberLive.Index do defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do field = determine_field(socket.assigns.sort_field, sf) order = determine_order(socket.assigns.sort_order, so) + old_field = socket.assigns.sort_field socket |> assign(:sort_field, field) |> assign(:sort_order, order) + |> assign(:sort_needs_update, old_field != field or socket.assigns.sort_order != order) + |> assign(:previous_sort_field, old_field) end defp maybe_update_sort(socket, _), do: socket @@ -1261,17 +1285,27 @@ defmodule MvWeb.MemberLive.Index do defp determine_field(default, nil), do: default defp determine_field(default, sf) when is_binary(sf) do - computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) + # Handle "groups" specially - it's in computed_member_fields() but can be sorted + if sf == "groups" do + :groups + else + computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) - if sf in computed_strings, - do: default, - else: determine_field_after_computed_check(default, sf) + if sf in computed_strings, + do: default, + else: determine_field_after_computed_check(default, sf) + end end defp determine_field(default, sf) when is_atom(sf) do - if sf in FieldVisibility.computed_member_fields(), - do: default, - else: determine_field_after_computed_check(default, sf) + # Handle :groups specially - it's in computed_member_fields() but can be sorted + if sf == :groups do + :groups + else + if sf in FieldVisibility.computed_member_fields(), + do: default, + else: determine_field_after_computed_check(default, sf) + end end defp determine_field(default, _), do: default @@ -1620,9 +1654,13 @@ defmodule MvWeb.MemberLive.Index do FieldVisibility.computed_member_fields() |> Enum.filter(&(&1 in member_fields_computed)) - # Groups is always included in export if it's visible in the table - # (groups column is always shown in the table, so we always include it in export) - member_fields_with_groups = ordered_member_fields_db ++ ["groups"] + # 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 # Order custom fields like the table (same as dynamic_cols / all_custom_fields order) ordered_custom_field_ids = @@ -1632,10 +1670,11 @@ defmodule MvWeb.MemberLive.Index do %{ 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), + 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: diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index f8be88d..fa0f43a 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -331,6 +331,7 @@ <:col :let={member} + :if={:groups in @member_fields_visible} label={ ~H""" <.live_component 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 0b0cb67..6427d4c 100644 --- a/lib/mv_web/live/member_live/index/field_visibility.ex +++ b/lib/mv_web/live/member_live/index/field_visibility.ex @@ -28,7 +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. - @pseudo_member_fields [:membership_fee_status] + # Groups is also a pseudo field (not a DB attribute, but displayed in the table). + @pseudo_member_fields [:membership_fee_status, :groups] # Export/API may accept this as alias; must not appear in the UI options list. @export_only_alias :payment_status @@ -201,7 +202,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do """ @spec get_visible_member_fields_computed(%{String.t() => boolean()}) :: [atom()] def get_visible_member_fields_computed(field_selection) when is_map(field_selection) do - computed_set = MapSet.new(@pseudo_member_fields) + computed_set = MapSet.new([:membership_fee_status]) field_selection |> Enum.filter(fn {field_string, visible} -> diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 0d661cf..c7e438a 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2205,6 +2205,7 @@ msgstr "Gruppe erfolgreich gespeichert." #: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Groups" msgstr "Gruppen" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 0aef1b3..0ad9170 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2206,6 +2206,7 @@ msgstr "" #: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Groups" msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 371a028..bfa6bcc 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2206,6 +2206,7 @@ msgstr "" #: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Groups" msgstr "" -- 2.47.2 From cb932ad6ef56f77513872f1e26603fc27732060e Mon Sep 17 00:00:00 2001 From: carla Date: Fri, 20 Feb 2026 08:45:55 +0100 Subject: [PATCH 3/4] feat: respects sorting groups for export --- lib/mv/membership/member_export/build.ex | 68 ++++++++++---- .../controllers/member_export_controller.ex | 93 ++++++++++++------- lib/mv_web/live/member_live/index.ex | 7 -- 3 files changed, 111 insertions(+), 57 deletions(-) diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex index 323681d..ff8cf76 100644 --- a/lib/mv/membership/member_export/build.ex +++ b/lib/mv/membership/member_export/build.ex @@ -244,16 +244,22 @@ defmodule Mv.Membership.MemberExport.Build do defp maybe_sort(query, _field, nil), do: {query, false} defp maybe_sort(query, field, order) when is_binary(field) do - if custom_field_sort?(field) do - {query, true} - else - field_atom = String.to_existing_atom(field) + cond do + field == "groups" -> + # Groups sort → in-memory nach dem Read (wie Tabelle) + {query, true} - 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 + 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 end rescue ArgumentError -> {query, false} @@ -263,21 +269,45 @@ defmodule Mv.Membership.MemberExport.Build do do: [] defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do - id_str = String.trim_leading(field, @custom_field_prefix) - custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) + if field == "groups" do + sort_members_by_groups_export(members, order) + else + id_str = String.trim_leading(field, @custom_field_prefix) + custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) - if is_nil(custom_field), do: members + if is_nil(custom_field), do: members - key_fn = fn member -> - cfv = find_cfv(member, custom_field) - raw = if cfv, do: cfv.value, else: nil - MemberExportSort.custom_field_sort_key(custom_field.value_type, raw) + key_fn = fn member -> + cfv = find_cfv(member, custom_field) + raw = if cfv, do: cfv.value, else: nil + MemberExportSort.custom_field_sort_key(custom_field.value_type, raw) + end + + members + |> Enum.map(fn m -> {m, key_fn.(m)} end) + |> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end) + |> Enum.map(fn {m, _} -> m end) + end + end + + defp sort_members_by_groups_export(members, order) do + # Members with groups first, then by first group name alphabetically (min = first by sort order) + # Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2 + first_group_name = fn member -> + (member.groups || []) + |> Enum.map(& &1.name) + |> Enum.min(fn -> nil end) end members - |> Enum.map(fn m -> {m, key_fn.(m)} end) - |> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end) - |> Enum.map(fn {m, _} -> m end) + |> Enum.sort_by(fn member -> + name = first_group_name.(member) + # Nil (no groups) sorts last in asc, first in desc + {name == nil, name || ""} + end) + |> then(fn list -> + if order == "desc", do: Enum.reverse(list), else: list + end) end defp find_cfv(member, custom_field) do diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex index 911e6d9..4ed8f2d 100644 --- a/lib/mv_web/controllers/member_export_controller.ex +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -341,17 +341,23 @@ defmodule MvWeb.MemberExportController do defp maybe_sort_export(query, _field, nil), do: {query, false} defp maybe_sort_export(query, field, order) when is_binary(field) do - if custom_field_sort?(field) do - # Custom field sort → in-memory nach dem Read (wie Tabelle) - {query, true} - else - field_atom = String.to_existing_atom(field) + cond do + field == "groups" -> + # Groups sort → in-memory nach dem Read (wie Tabelle) + {query, true} - 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 + 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 end rescue ArgumentError -> {query, false} @@ -370,35 +376,60 @@ defmodule MvWeb.MemberExportController do defp sort_members_by_custom_field_export(members, field, order, custom_fields) when is_binary(field) do order = order || "asc" - id_str = String.trim_leading(field, @custom_field_prefix) - custom_field = - Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) - - if is_nil(custom_field) do - members + if field == "groups" do + sort_members_by_groups_export(members, order) else - # Match table: - # 1) values first, empty last - # 2) sort only values - # 3) for desc, reverse only the values-part - {with_values, without_values} = - Enum.split_with(members, fn member -> - has_non_empty_custom_field_value?(member, custom_field) - end) + id_str = String.trim_leading(field, @custom_field_prefix) - sorted_with_values = - Enum.sort_by(with_values, fn member -> - extract_member_sort_value(member, custom_field) - end) + custom_field = + Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) - sorted_with_values = - if order == "desc", do: Enum.reverse(sorted_with_values), else: sorted_with_values + if is_nil(custom_field) do + members + else + # Match table: + # 1) values first, empty last + # 2) sort only values + # 3) for desc, reverse only the values-part + {with_values, without_values} = + Enum.split_with(members, fn member -> + has_non_empty_custom_field_value?(member, custom_field) + end) - sorted_with_values ++ without_values + sorted_with_values = + Enum.sort_by(with_values, fn member -> + extract_member_sort_value(member, custom_field) + end) + + sorted_with_values = + if order == "desc", do: Enum.reverse(sorted_with_values), else: sorted_with_values + + sorted_with_values ++ without_values + end end end + defp sort_members_by_groups_export(members, order) do + # Members with groups first, then by first group name alphabetically (min = first by sort order) + # Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2 + first_group_name = fn member -> + (member.groups || []) + |> Enum.map(& &1.name) + |> Enum.min(fn -> nil end) + end + + members + |> Enum.sort_by(fn member -> + name = first_group_name.(member) + # Nil (no groups) sorts last in asc, first in desc + {name == nil, name || ""} + end) + |> then(fn list -> + if order == "desc", do: Enum.reverse(list), else: list + end) + end + defp has_non_empty_custom_field_value?(member, custom_field) do case find_cfv(member, custom_field) do nil -> diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 526f858..9d93b08 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -1066,13 +1066,6 @@ defmodule MvWeb.MemberLive.Index do end end - defp computed_field?(field) do - computed_atoms = FieldVisibility.computed_member_fields() - computed_strings = Enum.map(computed_atoms, &Atom.to_string/1) - - (is_atom(field) and field in computed_atoms) or - (is_binary(field) and field in computed_strings) - end defp apply_sort_to_query(query, field, order) do cond do -- 2.47.2 From 397f7a7975bc1ffd8d2bad816a92d43343be2dc3 Mon Sep 17 00:00:00 2001 From: carla Date: Fri, 20 Feb 2026 09:16:38 +0100 Subject: [PATCH 4/4] fix linting --- lib/mv/membership/member_export/build.ex | 40 ++++++++++------ .../controllers/member_export_controller.ex | 48 ++++++++++--------- lib/mv_web/live/member_live/index.ex | 2 +- 3 files changed, 52 insertions(+), 38 deletions(-) diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex index ff8cf76..9e0cc7b 100644 --- a/lib/mv/membership/member_export/build.ex +++ b/lib/mv/membership/member_export/build.ex @@ -272,24 +272,34 @@ defmodule Mv.Membership.MemberExport.Build do if field == "groups" do sort_members_by_groups_export(members, order) else - id_str = String.trim_leading(field, @custom_field_prefix) - custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) - - if is_nil(custom_field), do: members - - key_fn = fn member -> - cfv = find_cfv(member, custom_field) - raw = if cfv, do: cfv.value, else: nil - MemberExportSort.custom_field_sort_key(custom_field.value_type, raw) - end - - members - |> Enum.map(fn m -> {m, key_fn.(m)} end) - |> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end) - |> Enum.map(fn {m, _} -> m end) + sort_by_custom_field_value(members, field, order, custom_fields) end end + defp sort_by_custom_field_value(members, field, order, custom_fields) do + id_str = String.trim_leading(field, @custom_field_prefix) + custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) + + if is_nil(custom_field) do + members + else + sort_members_with_custom_field(members, custom_field, order) + end + end + + defp sort_members_with_custom_field(members, custom_field, order) do + key_fn = fn member -> + cfv = find_cfv(member, custom_field) + raw = if cfv, do: cfv.value, else: nil + MemberExportSort.custom_field_sort_key(custom_field.value_type, raw) + end + + members + |> Enum.map(fn m -> {m, key_fn.(m)} end) + |> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end) + |> Enum.map(fn {m, _} -> m end) + end + defp sort_members_by_groups_export(members, order) do # Members with groups first, then by first group name alphabetically (min = first by sort order) # Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2 diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex index 4ed8f2d..08bcba7 100644 --- a/lib/mv_web/controllers/member_export_controller.ex +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -380,33 +380,37 @@ defmodule MvWeb.MemberExportController do if field == "groups" do sort_members_by_groups_export(members, order) else - id_str = String.trim_leading(field, @custom_field_prefix) + sort_by_custom_field_value(members, field, order, custom_fields) + end + end - custom_field = - Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) + defp sort_by_custom_field_value(members, field, order, custom_fields) do + id_str = String.trim_leading(field, @custom_field_prefix) - if is_nil(custom_field) do - members - else - # Match table: - # 1) values first, empty last - # 2) sort only values - # 3) for desc, reverse only the values-part - {with_values, without_values} = - Enum.split_with(members, fn member -> - has_non_empty_custom_field_value?(member, custom_field) - end) + custom_field = + Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) - sorted_with_values = - Enum.sort_by(with_values, fn member -> - extract_member_sort_value(member, custom_field) - end) + if is_nil(custom_field) do + members + else + # Match table: + # 1) values first, empty last + # 2) sort only values + # 3) for desc, reverse only the values-part + {with_values, without_values} = + Enum.split_with(members, fn member -> + has_non_empty_custom_field_value?(member, custom_field) + end) - sorted_with_values = - if order == "desc", do: Enum.reverse(sorted_with_values), else: sorted_with_values + sorted_with_values = + Enum.sort_by(with_values, fn member -> + extract_member_sort_value(member, custom_field) + end) - sorted_with_values ++ without_values - end + sorted_with_values = + if order == "desc", do: Enum.reverse(sorted_with_values), else: sorted_with_values + + sorted_with_values ++ without_values end end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 9d93b08..218fa6f 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -686,6 +686,7 @@ defmodule MvWeb.MemberLive.Index do socket = if socket.assigns[:sort_needs_update] do old_field = socket.assigns[:previous_sort_field] || socket.assigns.sort_field + socket |> update_sort_components(old_field, socket.assigns.sort_field, socket.assigns.sort_order) |> assign(:sort_needs_update, false) @@ -1066,7 +1067,6 @@ defmodule MvWeb.MemberLive.Index do end end - defp apply_sort_to_query(query, field, order) do cond do # Groups sort -> after load (in memory) -- 2.47.2