From c82f4b7fd7c671d48222857cd599812c5e3fefb8 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 4 Feb 2026 16:40:41 +0100 Subject: [PATCH 1/5] feat: add csv export --- lib/mv/authorization/permission_sets.ex | 4 +- .../custom_field_value_formatter.ex | 55 ++++ lib/mv/membership/members_csv.ex | 91 ++++++ .../controllers/member_export_controller.ex | 291 ++++++++++++++++++ lib/mv_web/live/member_live/index.ex | 32 ++ lib/mv_web/live/member_live/index.html.heex | 14 + lib/mv_web/router.ex | 1 + 7 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 lib/mv/membership/custom_field_value_formatter.ex create mode 100644 lib/mv/membership/members_csv.ex create mode 100644 lib/mv_web/controllers/member_export_controller.ex diff --git a/lib/mv/authorization/permission_sets.ex b/lib/mv/authorization/permission_sets.ex index 858748d..ea0ddff 100644 --- a/lib/mv/authorization/permission_sets.ex +++ b/lib/mv/authorization/permission_sets.ex @@ -158,8 +158,9 @@ defmodule Mv.Authorization.PermissionSets do "/users/:id", "/users/:id/edit", "/users/:id/show/edit", - # Member list + # Member list and CSV export "/members", + "/members/export.csv", # Member detail "/members/:id", # Custom field values overview @@ -208,6 +209,7 @@ defmodule Mv.Authorization.PermissionSets do "/users/:id/edit", "/users/:id/show/edit", "/members", + "/members/export.csv", # Create member "/members/new", "/members/:id", diff --git a/lib/mv/membership/custom_field_value_formatter.ex b/lib/mv/membership/custom_field_value_formatter.ex new file mode 100644 index 0000000..9709353 --- /dev/null +++ b/lib/mv/membership/custom_field_value_formatter.ex @@ -0,0 +1,55 @@ +defmodule Mv.Membership.CustomFieldValueFormatter do + @moduledoc """ + Neutral formatter for custom field values (e.g. CSV export). + + Same logic as the member overview Formatter but without Gettext or web helpers, + so it can be used from the Membership context. For boolean: "Yes"/"No"; + for date: European format (dd.mm.yyyy). + """ + @doc """ + Formats a custom field value for plain text (e.g. CSV). + + Handles nil, Ash.Union, JSONB map, and direct values. Uses custom_field.value_type + for typing. Boolean -> "Yes"/"No", Date -> dd.mm.yyyy. + """ + def format_custom_field_value(nil, _custom_field), do: "" + + def format_custom_field_value(%Ash.Union{value: value, type: type}, custom_field) do + format_value_by_type(value, type, custom_field) + end + + def format_custom_field_value(value, custom_field) when is_map(value) do + type = Map.get(value, "type") || Map.get(value, "_union_type") + val = Map.get(value, "value") || Map.get(value, "_union_value") + format_value_by_type(val, type, custom_field) + end + + def format_custom_field_value(value, custom_field) do + format_value_by_type(value, custom_field.value_type, custom_field) + end + + defp format_value_by_type(value, :string, _), do: to_string(value) + defp format_value_by_type(value, :integer, _), do: to_string(value) + + defp format_value_by_type(value, type, _) when type in [:string, :email] and is_binary(value) do + if String.trim(value) == "", do: "", else: value + end + + defp format_value_by_type(value, :email, _), do: to_string(value) + defp format_value_by_type(value, :boolean, _) when value == true, do: "Yes" + defp format_value_by_type(value, :boolean, _) when value == false, do: "No" + defp format_value_by_type(value, :boolean, _), do: to_string(value) + + defp format_value_by_type(%Date{} = date, :date, _) do + Calendar.strftime(date, "%d.%m.%Y") + end + + defp format_value_by_type(value, :date, _) when is_binary(value) do + case Date.from_iso8601(value) do + {:ok, date} -> Calendar.strftime(date, "%d.%m.%Y") + _ -> value + end + end + + defp format_value_by_type(value, _type, _), do: to_string(value) +end diff --git a/lib/mv/membership/members_csv.ex b/lib/mv/membership/members_csv.ex new file mode 100644 index 0000000..6eab399 --- /dev/null +++ b/lib/mv/membership/members_csv.ex @@ -0,0 +1,91 @@ +defmodule Mv.Membership.MembersCSV do + @moduledoc """ + Exports members to CSV (RFC 4180) as iodata. + + Uses NimbleCSV.RFC4180 for encoding. Member fields are formatted as strings; + custom field values use the same formatting logic as the member overview (neutral formatter). + Column order for custom fields follows the key order of the `custom_fields_by_id` map. + """ + alias Mv.Membership.CustomFieldValueFormatter + alias NimbleCSV.RFC4180 + + @doc """ + Exports a list of members to CSV iodata. + + - `members` - List of member structs (with optional `custom_field_values` loaded) + - `member_fields` - List of member field names (strings, e.g. `["first_name", "email"]`) + - `custom_fields_by_id` - Map of custom_field_id => %CustomField{}. Key order defines column order. + + Returns iodata suitable for `IO.iodata_to_binary/1` or sending as response body. + """ + @spec export( + [struct()], + [String.t()], + %{optional(String.t() | Ecto.UUID.t()) => struct()} + ) :: iodata() + def export(members, member_fields, custom_fields_by_id) when is_list(members) do + custom_entries = custom_field_entries(custom_fields_by_id) + header = build_header(member_fields, custom_entries) + rows = Enum.map(members, &build_row(&1, member_fields, custom_entries)) + RFC4180.dump_to_iodata([header | rows]) + end + + defp custom_field_entries(by_id) when is_map(by_id) do + Enum.map(by_id, fn {id, cf} -> {to_string(id), cf} end) + end + + defp build_header(member_fields, custom_entries) do + member_headers = member_fields + custom_headers = Enum.map(custom_entries, fn {_id, cf} -> cf.name end) + member_headers ++ custom_headers + end + + defp build_row(member, member_fields, custom_entries) do + member_cells = Enum.map(member_fields, &format_member_field(member, &1)) + + custom_cells = + Enum.map(custom_entries, fn {id, cf} -> format_custom_field(member, id, cf) end) + + member_cells ++ custom_cells + end + + defp format_member_field(member, field_name) do + key = member_field_key(field_name) + value = Map.get(member, key) + format_member_value(value) + end + + defp member_field_key(field_name) when is_binary(field_name) do + try do + String.to_existing_atom(field_name) + rescue + ArgumentError -> field_name + end + end + + defp format_member_value(nil), do: "" + defp format_member_value(true), do: "true" + defp format_member_value(false), do: "false" + defp format_member_value(%Date{} = d), do: Date.to_iso8601(d) + 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_custom_field(member, custom_field_id, custom_field) do + cfv = find_custom_field_value(member, custom_field_id) + + if cfv, + do: CustomFieldValueFormatter.format_custom_field_value(cfv.value, custom_field), + else: "" + end + + defp find_custom_field_value(member, custom_field_id) do + values = Map.get(member, :custom_field_values) || [] + id_str = to_string(custom_field_id) + + Enum.find(values, fn cfv -> + to_string(cfv.custom_field_id) == id_str or + (Map.get(cfv, :custom_field) && to_string(cfv.custom_field.id) == id_str) + end) + end +end diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex new file mode 100644 index 0000000..801bf0a --- /dev/null +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -0,0 +1,291 @@ +defmodule MvWeb.MemberExportController do + @moduledoc """ + Controller for CSV export of members. + + POST /members/export.csv with form param "payload" (JSON string). + Same permission and actor context as the member overview; 403 if unauthorized. + """ + use MvWeb, :controller + + require Ash.Query + import Ash.Expr + + alias Mv.Membership.Member + alias Mv.Membership.CustomField + alias Mv.Membership.MembersCSV + alias Mv.Authorization.Actor + + @member_fields_allowlist Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + @custom_field_prefix Mv.Constants.custom_field_prefix() + + def export(conn, params) do + actor = current_actor(conn) + if is_nil(actor), do: return_forbidden(conn) + + case params["payload"] do + nil -> + conn + |> put_status(400) + |> put_resp_content_type("application/json") + |> json(%{error: "payload required"}) + + payload when is_binary(payload) -> + case Jason.decode(payload) do + {:ok, decoded} when is_map(decoded) -> + parsed = parse_and_validate(decoded) + run_export(conn, actor, parsed) + + _ -> + conn + |> put_status(400) + |> put_resp_content_type("application/json") + |> json(%{error: "invalid JSON"}) + end + end + end + + defp current_actor(conn) do + conn.assigns[:current_user] + |> Actor.ensure_loaded() + end + + defp return_forbidden(conn) do + conn + |> put_status(403) + |> put_resp_content_type("application/json") + |> json(%{error: "Forbidden"}) + |> halt() + end + + defp parse_and_validate(params) do + %{ + selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")), + member_fields: filter_allowed_member_fields(extract_list(params, "member_fields")), + custom_field_ids: filter_valid_uuids(extract_list(params, "custom_field_ids")), + query: extract_string(params, "query"), + sort_field: extract_string(params, "sort_field"), + sort_order: extract_sort_order(params) + } + end + + defp extract_list(params, key) do + case Map.get(params, key) do + list when is_list(list) -> list + _ -> [] + end + end + + defp extract_string(params, key) do + case Map.get(params, key) do + s when is_binary(s) -> s + _ -> nil + end + end + + defp extract_sort_order(params) do + case Map.get(params, "sort_order") do + "asc" -> "asc" + "desc" -> "desc" + _ -> nil + end + end + + defp filter_allowed_member_fields(field_list) do + allowlist = MapSet.new(@member_fields_allowlist) + + field_list + |> Enum.filter(fn field -> is_binary(field) and MapSet.member?(allowlist, field) end) + |> Enum.uniq() + end + + defp filter_valid_uuids(id_list) when is_list(id_list) do + id_list + |> Enum.filter(fn id -> is_binary(id) and match?({:ok, _}, Ecto.UUID.cast(id)) end) + |> Enum.uniq() + end + + defp run_export(conn, actor, parsed) do + with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(parsed.custom_field_ids, actor), + {:ok, members} <- load_members_for_export(actor, parsed, custom_fields_by_id) do + csv_iodata = MembersCSV.export(members, parsed.member_fields, custom_fields_by_id) + filename = "members-#{Date.utc_today()}.csv" + + send_download( + conn, + {:binary, IO.iodata_to_binary(csv_iodata)}, + filename: filename, + content_type: "text/csv; charset=utf-8" + ) + else + {:error, :forbidden} -> + return_forbidden(conn) + end + end + + defp load_custom_fields_by_id([], _actor), do: {:ok, %{}} + + defp load_custom_fields_by_id(custom_field_ids, actor) do + query = + CustomField + |> Ash.Query.filter(expr(id in ^custom_field_ids)) + |> Ash.Query.select([:id, :name, :value_type]) + + case Ash.read(query, actor: actor) do + {:ok, custom_fields} -> + by_id = build_custom_fields_by_id(custom_field_ids, custom_fields) + {:ok, by_id} + + {:error, %Ash.Error.Forbidden{}} -> + {:error, :forbidden} + end + end + + defp build_custom_fields_by_id(custom_field_ids, custom_fields) do + Enum.reduce(custom_field_ids, %{}, fn id, acc -> + find_and_add_custom_field(acc, id, custom_fields) + end) + end + + defp find_and_add_custom_field(acc, id, custom_fields) do + case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do + nil -> acc + cf -> Map.put(acc, id, cf) + end + end + + defp load_members_for_export(actor, parsed, custom_fields_by_id) do + select_fields = [:id] ++ Enum.map(parsed.member_fields, &String.to_existing_atom/1) + + query = + Member + |> Ash.Query.new() + |> Ash.Query.select(select_fields) + |> load_custom_field_values_query(parsed.custom_field_ids) + + query = + if parsed.selected_ids != [] do + Ash.Query.filter(query, expr(id in ^parsed.selected_ids)) + else + query + |> apply_search_export(parsed.query) + |> then(fn q -> + {q, _sort_after_load} = maybe_sort_export(q, parsed.sort_field, parsed.sort_order) + q + end) + end + + case Ash.read(query, actor: actor) do + {:ok, members} -> + members = + if parsed.selected_ids == [] and sort_after_load?(parsed.sort_field) do + sort_members_by_custom_field_export( + members, + parsed.sort_field, + parsed.sort_order, + Map.values(custom_fields_by_id) + ) + else + # selected_ids != []: no sort. selected_ids == [] and DB sort: already in query. + members + end + + {:ok, members} + + {:error, %Ash.Error.Forbidden{}} -> + {:error, :forbidden} + end + end + + defp load_custom_field_values_query(query, []), do: query + + defp load_custom_field_values_query(query, custom_field_ids) do + cfv_query = + Mv.Membership.CustomFieldValue + |> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids)) + |> Ash.Query.load(custom_field: [:id, :name, :value_type]) + + Ash.Query.load(query, custom_field_values: cfv_query) + end + + defp apply_search_export(query, nil), do: query + defp apply_search_export(query, ""), do: query + + defp apply_search_export(query, q) when is_binary(q) do + if String.trim(q) != "" do + Member.fuzzy_search(query, %{query: q}) + else + query + end + end + + defp maybe_sort_export(query, nil, _order), do: {query, false} + 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 + {query, true} + else + 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} + end + + defp sort_after_load?(field) when is_binary(field), + do: String.starts_with?(field, @custom_field_prefix) + + defp sort_after_load?(_), do: false + + defp sort_members_by_custom_field_export(members, _field, _order, _custom_fields) + when members == [], + do: [] + + defp sort_members_by_custom_field_export(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 is_nil(custom_field), do: members + + extract_sort_val = fn member -> + cfv = find_cfv(member, custom_field) + if cfv, do: extract_sort_value(cfv.value, custom_field.value_type), else: nil + end + + sorted = + members + |> Enum.sort_by(extract_sort_val, fn + nil, _ -> false + _, nil -> true + a, b -> if order == "desc", do: a >= b, else: a <= b + end) + + if order == "desc", do: Enum.reverse(sorted), else: sorted + end + + defp find_cfv(member, custom_field) do + (member.custom_field_values || []) + |> Enum.find(fn cfv -> + to_string(cfv.custom_field_id) == to_string(custom_field.id) or + (Map.get(cfv, :custom_field) && + to_string(cfv.custom_field.id) == to_string(custom_field.id)) + end) + end + + defp extract_sort_value(%Ash.Union{value: value, type: type}, _), + do: extract_sort_value(value, type) + + defp extract_sort_value(value, :string) when is_binary(value), do: value + defp extract_sort_value(value, :integer) when is_integer(value), do: value + defp extract_sort_value(value, :boolean) when is_boolean(value), do: value + defp extract_sort_value(%Date{} = d, :date), do: d + defp extract_sort_value(value, :email) when is_binary(value), do: value + defp extract_sort_value(value, _), do: to_string(value) + + defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix) +end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 673502d..acafcf3 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -131,6 +131,7 @@ defmodule MvWeb.MemberLive.Index do ) |> assign(:show_current_cycle, false) |> assign(:membership_fee_status_filter, nil) + |> assign_export_payload() # We call handle params to use the query from the URL {:ok, socket} @@ -1729,5 +1730,36 @@ defmodule MvWeb.MemberLive.Index do |> assign(:selected_count, selected_count) |> assign(:any_selected?, any_selected?) |> assign(:mailto_bcc, mailto_bcc) + |> assign_export_payload() end + + # Builds the export payload map and assigns :export_payload_json for the CSV export form. + # Called when selection, visible fields, query, or sort change so the form always has current data. + defp assign_export_payload(socket) do + payload = build_export_payload(socket) + assign(socket, :export_payload_json, Jason.encode!(payload)) + end + + defp build_export_payload(socket) do + member_fields_visible = socket.assigns[:member_fields_visible] || [] + visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] + + %{ + selected_ids: socket.assigns.selected_members |> MapSet.to_list(), + member_fields: Enum.map(member_fields_visible, &Atom.to_string/1), + custom_field_ids: visible_custom_field_ids, + query: socket.assigns[:query] || nil, + sort_field: export_sort_field(socket.assigns[:sort_field]), + sort_order: export_sort_order(socket.assigns[:sort_order]) + } + end + + 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 end diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 394db2c..79017a9 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -2,6 +2,20 @@ <.header> {gettext("Members")} <:actions> +
+ + + +
<.button class="secondary" id="copy-emails-btn" diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index b5bc616..97e0642 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -91,6 +91,7 @@ defmodule MvWeb.Router do # Import/Export (Admin only) live "/admin/import-export", ImportExportLive + post "/members/export.csv", MemberExportController, :export post "/set_locale", LocaleController, :set_locale end From b429a4dbb6b2e9318fed6c22eb7c13f9d4cc6634 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 4 Feb 2026 16:43:12 +0100 Subject: [PATCH 2/5] test: adds tests --- test/mv/membership/members_csv_test.exs | 124 +++++++++++++ .../member_export_controller_test.exs | 146 +++++++++++++++ test/mv_web/live/import_export_live_test.exs | 7 + test/mv_web/live/profile_navigation_test.exs | 2 +- .../member_live/form_error_handling_test.exs | 1 + test/mv_web/member_live/index_test.exs | 170 ++++++++++++------ 6 files changed, 391 insertions(+), 59 deletions(-) create mode 100644 test/mv/membership/members_csv_test.exs create mode 100644 test/mv_web/controllers/member_export_controller_test.exs diff --git a/test/mv/membership/members_csv_test.exs b/test/mv/membership/members_csv_test.exs new file mode 100644 index 0000000..a2228aa --- /dev/null +++ b/test/mv/membership/members_csv_test.exs @@ -0,0 +1,124 @@ +defmodule Mv.Membership.MembersCSVTest do + use ExUnit.Case, async: true + + alias Mv.Membership.MembersCSV + + describe "export/3" do + test "returns CSV with header and one data row (member fields only)" do + member = %{first_name: "Jane", email: "jane@example.com"} + member_fields = ["first_name", "email"] + custom_fields_by_id = %{} + + iodata = MembersCSV.export([member], member_fields, custom_fields_by_id) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "first_name" + assert csv =~ "email" + assert csv =~ "Jane" + assert csv =~ "jane@example.com" + # One header line, one data line + lines = String.split(csv, "\n", trim: true) + assert length(lines) == 2 + end + + test "escapes cell containing comma (RFC 4180 quoted)" do + member = %{first_name: "Doe, John", email: "john@example.com"} + member_fields = ["first_name", "email"] + custom_fields_by_id = %{} + + iodata = MembersCSV.export([member], member_fields, custom_fields_by_id) + csv = IO.iodata_to_binary(iodata) + + # Comma inside value must be quoted so the cell is one field + assert csv =~ ~s("Doe, John") + assert csv =~ "john@example.com" + end + + test "escapes cell containing double-quote (RFC 4180 doubled and quoted)" do + member = %{first_name: ~s(He said "Hi"), email: "a@b.com"} + member_fields = ["first_name", "email"] + custom_fields_by_id = %{} + + iodata = MembersCSV.export([member], member_fields, custom_fields_by_id) + csv = IO.iodata_to_binary(iodata) + + # Double-quote inside value must be doubled and cell quoted + assert csv =~ ~s("He said ""Hi""") + assert csv =~ "a@b.com" + end + + test "formats date as ISO8601 for member fields" do + member = %{first_name: "D", email: "d@d.com", join_date: ~D[2024-03-15]} + iodata = MembersCSV.export([member], ["first_name", "email", "join_date"], %{}) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "2024-03-15" + assert csv =~ "join_date" + end + + test "formats nil as empty string" do + member = %{first_name: "Only", last_name: nil, email: "x@y.com"} + member_fields = ["first_name", "last_name", "email"] + custom_fields_by_id = %{} + + iodata = MembersCSV.export([member], member_fields, custom_fields_by_id) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "first_name" + assert csv =~ "Only" + assert csv =~ "x@y.com" + # Nil becomes empty; between Only and x@y we have empty (e.g. Only,,x@y.com) + assert csv =~ "Only,,x@y" + end + + test "formats boolean as true/false" do + # Use a field we can set to boolean via a custom-like struct - member has no boolean field. + # So we test via custom field instead. + custom_cf = %{id: "cf-1", name: "Active", value_type: :boolean} + custom_fields_by_id = %{"cf-1" => custom_cf} + + member_with_cfv = %{ + first_name: "Test", + email: "e@e.com", + custom_field_values: [ + %{custom_field_id: "cf-1", value: true, custom_field: custom_cf} + ] + } + + iodata = + MembersCSV.export( + [member_with_cfv], + ["first_name", "email"], + custom_fields_by_id + ) + + csv = IO.iodata_to_binary(iodata) + assert csv =~ "Active" + # Formatter yields "Yes" for true (gettext) + assert csv =~ "Yes" + end + + test "includes custom field columns in header and rows (order from map)" do + cf1 = %{id: "a", name: "Custom1", value_type: :string} + cf2 = %{id: "b", name: "Custom2", value_type: :string} + # Map order: a then b + custom_fields_by_id = %{"a" => cf1, "b" => cf2} + + member = %{ + first_name: "M", + email: "m@m.com", + custom_field_values: [ + %{custom_field_id: "a", value: "v1", custom_field: cf1}, + %{custom_field_id: "b", value: "v2", custom_field: cf2} + ] + } + + iodata = MembersCSV.export([member], ["first_name", "email"], custom_fields_by_id) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "first_name,email,Custom1,Custom2" + assert csv =~ "v1" + assert csv =~ "v2" + end + end +end diff --git a/test/mv_web/controllers/member_export_controller_test.exs b/test/mv_web/controllers/member_export_controller_test.exs new file mode 100644 index 0000000..122011b --- /dev/null +++ b/test/mv_web/controllers/member_export_controller_test.exs @@ -0,0 +1,146 @@ +defmodule MvWeb.MemberExportControllerTest do + use MvWeb.ConnCase, async: true + + alias Mv.Fixtures + + defp csrf_token_from_conn(conn) do + get_session(conn, "_csrf_token") || csrf_token_from_html(response(conn, 200)) + end + + defp csrf_token_from_html(html) when is_binary(html) do + case Regex.run(~r/name="csrf-token"\s+content="([^"]+)"/, html) do + [_, token] -> token + _ -> nil + end + end + + describe "POST /members/export.csv" do + setup %{conn: conn} do + # Create 3 members for export tests + m1 = + Fixtures.member_fixture(%{ + first_name: "Alice", + last_name: "One", + email: "alice.one@example.com" + }) + + m2 = + Fixtures.member_fixture(%{ + first_name: "Bob", + last_name: "Two", + email: "bob.two@example.com" + }) + + m3 = + Fixtures.member_fixture(%{ + first_name: "Carol", + last_name: "Three", + email: "carol.three@example.com" + }) + + %{member1: m1, member2: m2, member3: m3, conn: conn} + end + + test "selected export: returns 200, text/csv, header + exactly 2 data rows", %{ + conn: conn, + member1: m1, + member2: m2 + } do + payload = %{ + "selected_ids" => [m1.id, m2.id], + "member_fields" => ["first_name", "last_name", "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 + assert get_resp_header(conn, "content-type") |> List.first() =~ "text/csv" + + body = response(conn, 200) + lines = String.split(body, "\n", trim: true) + + # Header + 2 data rows + assert length(lines) == 3 + assert hd(lines) =~ "first_name" + assert hd(lines) =~ "email" + assert body =~ "Alice" + assert body =~ "Bob" + refute body =~ "Carol" + end + + test "all export: selected_ids=[] returns all members (at least 3 data rows)", %{ + conn: conn, + member1: _m1, + member2: _m2, + member3: _m3 + } do + payload = %{ + "selected_ids" => [], + "member_fields" => ["first_name", "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) + lines = String.split(body, "\n", trim: true) + + # Header + at least 3 data rows + assert length(lines) >= 4 + assert hd(lines) =~ "first_name" + assert body =~ "Alice" + assert body =~ "Bob" + assert body =~ "Carol" + end + + test "whitelist: unknown member_fields are not in header", %{conn: conn, member1: m1} do + payload = %{ + "selected_ids" => [m1.id], + "member_fields" => ["first_name", "unknown_field", "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 |> String.split("\n", trim: true) |> hd() + + assert header =~ "first_name" + assert header =~ "email" + refute header =~ "unknown_field" + end + end +end diff --git a/test/mv_web/live/import_export_live_test.exs b/test/mv_web/live/import_export_live_test.exs index a165ea6..653cd8d 100644 --- a/test/mv_web/live/import_export_live_test.exs +++ b/test/mv_web/live/import_export_live_test.exs @@ -19,6 +19,7 @@ defmodule MvWeb.ImportExportLiveTest do end describe "Import/Export LiveView" do + @describetag :ui setup %{conn: conn} do admin_user = Mv.Fixtures.user_with_role_fixture("admin") conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user) @@ -45,6 +46,7 @@ defmodule MvWeb.ImportExportLiveTest do end describe "CSV Import Section" do + @describetag :ui setup %{conn: conn} do admin_user = Mv.Fixtures.user_with_role_fixture("admin") conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user) @@ -524,6 +526,7 @@ defmodule MvWeb.ImportExportLiveTest do # Verified by import-results-panel existence above end + @tag :ui test "A11y: file input has label", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/admin/import-export") @@ -532,6 +535,7 @@ defmodule MvWeb.ImportExportLiveTest do html =~ ~r/]*>.*CSV File/i end + @tag :ui test "A11y: status/progress container has aria-live", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/admin/import-export") @@ -540,6 +544,7 @@ defmodule MvWeb.ImportExportLiveTest do assert html =~ ~r/aria-live=["']polite["']/i end + @tag :ui test "A11y: links have descriptive text", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/admin/import-export") @@ -642,6 +647,7 @@ defmodule MvWeb.ImportExportLiveTest do html =~ "Failed to prepare" end + @tag :ui test "wrong file type (.txt): upload shows error", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/admin/import-export") @@ -659,6 +665,7 @@ defmodule MvWeb.ImportExportLiveTest do assert html =~ "CSV" or html =~ "csv" or html =~ ".csv" end + @tag :ui test "file input has correct accept attribute for CSV only", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/admin/import-export") diff --git a/test/mv_web/live/profile_navigation_test.exs b/test/mv_web/live/profile_navigation_test.exs index b8562cd..72d974c 100644 --- a/test/mv_web/live/profile_navigation_test.exs +++ b/test/mv_web/live/profile_navigation_test.exs @@ -61,7 +61,7 @@ defmodule MvWeb.ProfileNavigationTest do end @tag :skip - # TODO: Implement user initials in navbar avatar - see issue #170 + # Note: User initials in navbar avatar - see issue #170 test "shows user initials in avatar", %{conn: conn} do # Setup: Create and login a user user = create_test_user(%{email: "test.user@example.com"}) diff --git a/test/mv_web/member_live/form_error_handling_test.exs b/test/mv_web/member_live/form_error_handling_test.exs index b2bf804..9e55cd8 100644 --- a/test/mv_web/member_live/form_error_handling_test.exs +++ b/test/mv_web/member_live/form_error_handling_test.exs @@ -9,6 +9,7 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do require Ash.Query describe "error handling - flash messages" do + @describetag :ui test "shows flash message when member creation fails with validation error", %{conn: conn} do system_actor = Mv.Helpers.SystemActor.get_system_actor() diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 3234761..e560c92 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -46,78 +46,82 @@ defmodule MvWeb.MemberLive.IndexTest do |> Ash.create!(actor: actor) end - test "shows translated title in German", %{conn: conn} do - conn = conn_with_oidc_user(conn) - conn = Plug.Test.init_test_session(conn, locale: "de") - {:ok, _view, html} = live(conn, "/members") - # Expected German title - assert html =~ "Mitglieder" - end + describe "translations" do + @describetag :ui + test "shows translated title in German", %{conn: conn} do + conn = conn_with_oidc_user(conn) + conn = Plug.Test.init_test_session(conn, locale: "de") + {:ok, _view, html} = live(conn, "/members") + # Expected German title + assert html =~ "Mitglieder" + end - test "shows translated title in English", %{conn: conn} do - conn = conn_with_oidc_user(conn) - Gettext.put_locale(MvWeb.Gettext, "en") - {:ok, _view, html} = live(conn, "/members") - # Expected English title - assert html =~ "Members" - end + test "shows translated title in English", %{conn: conn} do + conn = conn_with_oidc_user(conn) + Gettext.put_locale(MvWeb.Gettext, "en") + {:ok, _view, html} = live(conn, "/members") + # Expected English title + assert html =~ "Members" + end - test "shows translated button text in German", %{conn: conn} do - conn = conn_with_oidc_user(conn) - conn = Plug.Test.init_test_session(conn, locale: "de") - {:ok, _view, html} = live(conn, "/members/new") - assert html =~ "Speichern" - end + test "shows translated button text in German", %{conn: conn} do + conn = conn_with_oidc_user(conn) + conn = Plug.Test.init_test_session(conn, locale: "de") + {:ok, _view, html} = live(conn, "/members/new") + assert html =~ "Speichern" + end - test "shows translated button text in English", %{conn: conn} do - conn = conn_with_oidc_user(conn) - Gettext.put_locale(MvWeb.Gettext, "en") - {:ok, _view, html} = live(conn, "/members/new") - assert html =~ "Save" - end + test "shows translated button text in English", %{conn: conn} do + conn = conn_with_oidc_user(conn) + Gettext.put_locale(MvWeb.Gettext, "en") + {:ok, _view, html} = live(conn, "/members/new") + assert html =~ "Save" + end - test "shows translated flash message after creating a member in German", %{conn: conn} do - conn = conn_with_oidc_user(conn) - conn = Plug.Test.init_test_session(conn, locale: "de") - {:ok, form_view, _html} = live(conn, "/members/new") + test "shows translated flash message after creating a member in German", %{conn: conn} do + conn = conn_with_oidc_user(conn) + conn = Plug.Test.init_test_session(conn, locale: "de") + {:ok, form_view, _html} = live(conn, "/members/new") - form_data = %{ - "member[first_name]" => "Max", - "member[last_name]" => "Mustermann", - "member[email]" => "max@example.com" - } + form_data = %{ + "member[first_name]" => "Max", + "member[last_name]" => "Mustermann", + "member[email]" => "max@example.com" + } - # Submit form and follow the redirect to get the flash message - {:ok, index_view, _html} = - form_view - |> form("#member-form", form_data) - |> render_submit() - |> follow_redirect(conn, "/members") + # Submit form and follow the redirect to get the flash message + {:ok, index_view, _html} = + form_view + |> form("#member-form", form_data) + |> render_submit() + |> follow_redirect(conn, "/members") - assert has_element?(index_view, "#flash-group", "Mitglied wurde erfolgreich erstellt") - end + assert has_element?(index_view, "#flash-group", "Mitglied wurde erfolgreich erstellt") + end - test "shows translated flash message after creating a member in English", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, form_view, _html} = live(conn, "/members/new") + test "shows translated flash message after creating a member in English", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, form_view, _html} = live(conn, "/members/new") - form_data = %{ - "member[first_name]" => "Max", - "member[last_name]" => "Mustermann", - "member[email]" => "max@example.com" - } + form_data = %{ + "member[first_name]" => "Max", + "member[last_name]" => "Mustermann", + "member[email]" => "max@example.com" + } - # Submit form and follow the redirect to get the flash message - {:ok, index_view, _html} = - form_view - |> form("#member-form", form_data) - |> render_submit() - |> follow_redirect(conn, "/members") + # Submit form and follow the redirect to get the flash message + {:ok, index_view, _html} = + form_view + |> form("#member-form", form_data) + |> render_submit() + |> follow_redirect(conn, "/members") - assert has_element?(index_view, "#flash-group", "Member created successfully") + assert has_element?(index_view, "#flash-group", "Member created successfully") + end end describe "sorting integration" do + @describetag :ui test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") @@ -200,6 +204,7 @@ defmodule MvWeb.MemberLive.IndexTest do end describe "URL param handling" do + @describetag :ui test "handle_params reads sort query and applies it", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc") @@ -226,6 +231,7 @@ defmodule MvWeb.MemberLive.IndexTest do end describe "search and sort integration" do + @describetag :ui test "search maintains sort state", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc") @@ -253,6 +259,7 @@ defmodule MvWeb.MemberLive.IndexTest do end end + @tag :ui test "handle_info(:search_changed) updates assigns with search results", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") @@ -521,6 +528,50 @@ defmodule MvWeb.MemberLive.IndexTest do end end + describe "export to CSV" do + setup do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, m1} = + Mv.Membership.create_member( + %{first_name: "Export", last_name: "One", email: "export1@example.com"}, + actor: system_actor + ) + + %{member1: m1} + end + + test "export button is rendered when no selection and shows (all)", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, html} = live(conn, "/members") + + # Button text shows "all" when 0 selected (locale-dependent) + assert html =~ "Export to CSV" + assert html =~ "all" or html =~ "All" + end + + test "after select_member event export button shows (1)", %{conn: conn, member1: member1} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + render_click(view, "select_member", %{"id" => member1.id}) + + html = render(view) + assert html =~ "Export to CSV" + assert html =~ "(1)" + end + + test "form has correct action and payload hidden input", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + assert html =~ "/members/export.csv" + assert html =~ ~s(name="payload") + assert html =~ ~s(type="hidden") + assert html =~ ~s(name="_csrf_token") + end + end + describe "cycle status filter" do # Helper to create a member (only used in this describe block) defp create_member(attrs, actor) do @@ -780,6 +831,7 @@ defmodule MvWeb.MemberLive.IndexTest do |> Ash.create!(actor: system_actor) end + @tag :ui test "mount initializes boolean_custom_field_filters as empty map", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") @@ -788,6 +840,7 @@ defmodule MvWeb.MemberLive.IndexTest do assert state.socket.assigns.boolean_custom_field_filters == %{} end + @tag :ui test "mount initializes boolean_custom_fields as empty list when no boolean fields exist", %{ conn: conn } do @@ -1762,6 +1815,7 @@ defmodule MvWeb.MemberLive.IndexTest do refute html_false =~ "NoValue" end + @tag :ui test "boolean custom field appears in filter dropdown after being added", %{conn: conn} do conn = conn_with_oidc_user(conn) From e7d63b9b0a204b7ea0217f58225e0e30a47c963c Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 4 Feb 2026 16:55:24 +0100 Subject: [PATCH 3/5] fix linting --- .../controllers/member_export_controller.ex | 28 ++++++++------ lib/mv_web/live/import_export_live.ex | 38 +++++++++++++++---- priv/gettext/de/LC_MESSAGES/default.po | 15 ++++++++ priv/gettext/default.pot | 15 ++++++++ priv/gettext/en/LC_MESSAGES/default.po | 15 ++++++++ 5 files changed, 93 insertions(+), 18 deletions(-) diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex index 801bf0a..cad32a2 100644 --- a/lib/mv_web/controllers/member_export_controller.ex +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -10,10 +10,10 @@ defmodule MvWeb.MemberExportController do require Ash.Query import Ash.Expr - alias Mv.Membership.Member - alias Mv.Membership.CustomField - alias Mv.Membership.MembersCSV alias Mv.Authorization.Actor + alias Mv.Membership.CustomField + alias Mv.Membership.Member + alias Mv.Membership.MembersCSV @member_fields_allowlist Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) @custom_field_prefix Mv.Constants.custom_field_prefix() @@ -100,7 +100,9 @@ defmodule MvWeb.MemberExportController do defp filter_valid_uuids(id_list) when is_list(id_list) do id_list - |> Enum.filter(fn id -> is_binary(id) and match?({:ok, _}, Ecto.UUID.cast(id)) end) + |> Enum.filter(fn id -> + is_binary(id) and match?({:ok, _}, Ecto.UUID.cast(id)) + end) |> Enum.uniq() end @@ -130,14 +132,18 @@ defmodule MvWeb.MemberExportController do |> Ash.Query.filter(expr(id in ^custom_field_ids)) |> Ash.Query.select([:id, :name, :value_type]) - case Ash.read(query, actor: actor) do - {:ok, custom_fields} -> - by_id = build_custom_fields_by_id(custom_field_ids, custom_fields) - {:ok, by_id} + query + |> Ash.read(actor: actor) + |> handle_custom_fields_read_result(custom_field_ids) + end - {:error, %Ash.Error.Forbidden{}} -> - {:error, :forbidden} - end + defp handle_custom_fields_read_result({:ok, custom_fields}, custom_field_ids) do + by_id = build_custom_fields_by_id(custom_field_ids, custom_fields) + {:ok, by_id} + end + + defp handle_custom_fields_read_result({:error, %Ash.Error.Forbidden{}}, _custom_field_ids) do + {:error, :forbidden} end defp build_custom_fields_by_id(custom_field_ids, custom_fields) do diff --git a/lib/mv_web/live/import_export_live.ex b/lib/mv_web/live/import_export_live.ex index 384c39b..86c3e1f 100644 --- a/lib/mv_web/live/import_export_live.ex +++ b/lib/mv_web/live/import_export_live.ex @@ -642,24 +642,48 @@ defmodule MvWeb.ImportExportLive do # Start async task to process chunk in production # Use start_child for fire-and-forget: no monitor, no Task messages # We only use our own send/2 messages for communication - Task.Supervisor.start_child(Mv.TaskSupervisor, fn -> - # Set locale in task process for translations - Gettext.put_locale(MvWeb.Gettext, locale) - - process_chunk_with_error_handling( + Task.Supervisor.start_child( + Mv.TaskSupervisor, + build_chunk_processing_task( chunk, import_state.column_map, import_state.custom_field_map, opts, live_view_pid, - idx + idx, + locale ) - end) + ) end {:noreply, socket} end + # Builds the task function for processing a chunk asynchronously. + defp build_chunk_processing_task( + chunk, + column_map, + custom_field_map, + opts, + live_view_pid, + idx, + locale + ) do + fn -> + # Set locale in task process for translations + Gettext.put_locale(MvWeb.Gettext, locale) + + process_chunk_with_error_handling( + chunk, + column_map, + custom_field_map, + opts, + live_view_pid, + idx + ) + end + end + # Handles chunk processing result from async task and schedules the next chunk. @spec handle_chunk_result( Phoenix.LiveView.Socket.t(), diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 4cc92f4..b0d4d2f 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2319,6 +2319,21 @@ msgstr "Mitgliederdaten verwalten" msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning." msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV Datei. Datenfelder müssen in Mila bereits angelegt sein, damit sie importiert werden können. sie müssen in der Liste der Mitgliederdaten als Datenfeld enthalten sein (z.B. E-Mail). Spalten mit unbekannten Spaltenüberschriften werden mit einer Warnung ignoriert." +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Export members to CSV" +msgstr "Mitglieder importieren (CSV)" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Export to CSV" +msgstr "Als CSV exportieren" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "all" +msgstr "alle" + #~ #: lib/mv_web/live/global_settings_live.ex #~ #, elixir-autogen, elixir-format, fuzzy #~ msgid "Custom Fields in CSV Import" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index d3da51f..e2192d0 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2319,3 +2319,18 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning." msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Export members to CSV" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Export to CSV" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "all" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index be17f98..1c59d95 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2320,6 +2320,21 @@ msgstr "" msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning." msgstr "" +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Export members to CSV" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Export to CSV" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "all" +msgstr "" + #~ #: lib/mv_web/live/global_settings_live.ex #~ #, elixir-autogen, elixir-format, fuzzy #~ msgid "Custom Fields in CSV Import" From 9b9e7ec99557b6c0275326c3753719e80c149859 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 5 Feb 2026 15:03:25 +0100 Subject: [PATCH 4/5] fix: sorting and filter for export --- lib/mv/membership/member_export.ex | 344 +++++++ lib/mv/membership/member_export_sort.ex | 44 + lib/mv/membership/members_csv.ex | 117 +-- lib/mv_web/components/core_components.ex | 8 +- .../controllers/member_export_controller.ex | 220 ++++- .../field_visibility_dropdown_component.ex | 29 +- lib/mv_web/live/member_live/index.ex | 853 ++++++------------ lib/mv_web/live/member_live/index.html.heex | 1 + .../member_live/index/field_visibility.ex | 110 ++- lib/mv_web/translations/member_fields.ex | 1 + 10 files changed, 1013 insertions(+), 714 deletions(-) create mode 100644 lib/mv/membership/member_export.ex create mode 100644 lib/mv/membership/member_export_sort.ex diff --git a/lib/mv/membership/member_export.ex b/lib/mv/membership/member_export.ex new file mode 100644 index 0000000..5f771cc --- /dev/null +++ b/lib/mv/membership/member_export.ex @@ -0,0 +1,344 @@ +defmodule Mv.Membership.MemberExport do + @moduledoc """ + Builds member list and column specs for CSV export. + + Used by `MvWeb.MemberExportController`. Does not perform translations; + the controller applies headers (e.g. via `MemberFields.label` / gettext) + and sends the download. + """ + + require Ash.Query + import Ash.Expr + + alias Mv.Membership.CustomField + alias Mv.Membership.Member + alias Mv.Membership.MemberExportSort + alias MvWeb.MemberLive.Index.MembershipFeeStatus + + @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ + ["membership_fee_status", "payment_status"] + @computed_export_fields ["membership_fee_status", "payment_status"] + @custom_field_prefix Mv.Constants.custom_field_prefix() + @domain_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + + @doc """ + Fetches members and column specs for export. + + - `actor` - Ash actor (e.g. current user) + - `parsed` - Map from controller's parse_and_validate (selected_ids, member_fields, etc.) + + Returns `{:ok, members, column_specs}` or `{:error, :forbidden}`. + Column specs have `:kind`, `:key`, and for custom fields `:custom_field`; + the controller adds `:header` and optional computed columns to members before CSV export. + """ + @spec fetch(struct(), map()) :: + {:ok, [struct()], [map()]} | {:error, :forbidden} + def fetch(actor, parsed) do + custom_field_ids_union = + (parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})) |> Enum.uniq() + + with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(custom_field_ids_union, actor), + {:ok, members} <- load_members(actor, parsed, custom_fields_by_id) do + column_specs = build_column_specs(parsed, custom_fields_by_id) + {:ok, members, column_specs} + end + end + + defp load_custom_fields_by_id([], _actor), do: {:ok, %{}} + + defp load_custom_fields_by_id(custom_field_ids, actor) do + query = + CustomField + |> Ash.Query.filter(expr(id in ^custom_field_ids)) + |> Ash.Query.select([:id, :name, :value_type]) + + case Ash.read(query, actor: actor) do + {:ok, custom_fields} -> + by_id = + Enum.reduce(custom_field_ids, %{}, fn id, acc -> + case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do + nil -> acc + cf -> Map.put(acc, id, cf) + end + end) + + {:ok, by_id} + + {:error, %Ash.Error.Forbidden{}} -> + {:error, :forbidden} + end + end + + defp build_column_specs(parsed, custom_fields_by_id) do + member_specs = + Enum.map(parsed.member_fields, fn f -> + if f in parsed.selectable_member_fields do + %{kind: :member_field, key: f} + else + %{kind: :computed, key: String.to_existing_atom(f)} + end + end) + + custom_specs = + parsed.custom_field_ids + |> Enum.map(fn id -> Map.get(custom_fields_by_id, id) end) + |> Enum.reject(&is_nil/1) + |> Enum.map(fn cf -> %{kind: :custom_field, key: cf.id, custom_field: cf} end) + + member_specs ++ custom_specs + end + + defp load_members(actor, parsed, custom_fields_by_id) do + select_fields = + [:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1) + + custom_field_ids_union = parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{}) + + need_cycles = + parsed.show_current_cycle or parsed.cycle_status_filter != nil or + parsed.computed_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) + + query = + if parsed.selected_ids != [] do + Ash.Query.filter(query, expr(id in ^parsed.selected_ids)) + else + query + |> apply_search(parsed.query) + |> then(fn q -> + {q, _sort_after_load} = maybe_sort(q, parsed.sort_field, parsed.sort_order) + q + end) + end + + case Ash.read(query, actor: actor) do + {:ok, members} -> + members = + if parsed.selected_ids == [] do + members + |> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle) + |> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters( + parsed.boolean_filters || %{}, + Map.values(custom_fields_by_id) + ) + else + members + end + + members = + if parsed.selected_ids == [] and sort_after_load?(parsed.sort_field) do + sort_members_by_custom_field( + members, + parsed.sort_field, + parsed.sort_order, + Map.values(custom_fields_by_id) + ) + else + members + end + + {:ok, members} + + {:error, %Ash.Error.Forbidden{}} -> + {:error, :forbidden} + end + end + + defp load_custom_field_values_query(query, []), do: query + + defp load_custom_field_values_query(query, custom_field_ids) do + cfv_query = + Mv.Membership.CustomFieldValue + |> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids)) + |> Ash.Query.load(custom_field: [:id, :name, :value_type]) + + Ash.Query.load(query, custom_field_values: cfv_query) + end + + defp apply_search(query, nil), do: query + defp apply_search(query, ""), do: query + + defp apply_search(query, q) when is_binary(q) do + if String.trim(q) != "" do + Member.fuzzy_search(query, %{query: q}) + else + query + end + end + + defp maybe_sort(query, nil, _order), do: {query, false} + 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) + + 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} + end + + defp sort_after_load?(field) when is_binary(field), + do: String.starts_with?(field, @custom_field_prefix) + + defp sort_after_load?(_), do: false + + defp sort_members_by_custom_field(members, _field, _order, _custom_fields) when members == [], + 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 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) + end + + defp find_cfv(member, custom_field) do + (member.custom_field_values || []) + |> Enum.find(fn cfv -> + to_string(cfv.custom_field_id) == to_string(custom_field.id) or + (Map.get(cfv, :custom_field) && + to_string(cfv.custom_field.id) == to_string(custom_field.id)) + end) + end + + 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, true, show_current) do + MembershipFeeStatus.load_cycles_for_members(query, show_current) + 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 + MembershipFeeStatus.filter_members_by_cycle_status(members, status, show_current) + end + + defp apply_cycle_status_filter(members, _status, _show_current), do: members + + # Called by controller to build parsed map from raw params (kept here so controller stays thin) + @doc """ + Parses and validates export params (from JSON payload). + + Returns a map with :selected_ids, :member_fields, :selectable_member_fields, + :computed_fields, :custom_field_ids, :query, :sort_field, :sort_order, + :show_current_cycle, :cycle_status_filter, :boolean_filters. + """ + @spec parse_params(map()) :: map() + def parse_params(params) do + member_fields = filter_allowed_member_fields(extract_list(params, "member_fields")) + {selectable_member_fields, computed_fields} = split_member_fields(member_fields) + + %{ + selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")), + member_fields: member_fields, + selectable_member_fields: selectable_member_fields, + computed_fields: computed_fields, + custom_field_ids: filter_valid_uuids(extract_list(params, "custom_field_ids")), + query: extract_string(params, "query"), + sort_field: extract_string(params, "sort_field"), + sort_order: extract_sort_order(params), + show_current_cycle: extract_boolean(params, "show_current_cycle"), + cycle_status_filter: extract_cycle_status_filter(params), + boolean_filters: extract_boolean_filters(params) + } + end + + defp split_member_fields(member_fields) do + selectable = Enum.filter(member_fields, fn f -> f in @domain_member_field_strings end) + computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end) + {selectable, computed} + end + + defp extract_boolean(params, key) do + case Map.get(params, key) do + true -> true + "true" -> true + _ -> false + end + end + + defp extract_cycle_status_filter(params) do + case Map.get(params, "cycle_status_filter") do + "paid" -> :paid + "unpaid" -> :unpaid + _ -> nil + end + end + + defp extract_boolean_filters(params) do + case Map.get(params, "boolean_filters") do + map when is_map(map) -> + map + |> Enum.filter(fn {k, v} -> is_binary(k) and is_boolean(v) end) + |> Enum.filter(fn {k, _} -> match?({:ok, _}, Ecto.UUID.cast(k)) end) + |> Enum.into(%{}) + + _ -> + %{} + end + end + + defp extract_list(params, key) do + case Map.get(params, key) do + list when is_list(list) -> list + _ -> [] + end + end + + defp extract_string(params, key) do + case Map.get(params, key) do + s when is_binary(s) -> s + _ -> nil + end + end + + defp extract_sort_order(params) do + case Map.get(params, "sort_order") do + "asc" -> "asc" + "desc" -> "desc" + _ -> nil + end + end + + defp filter_allowed_member_fields(field_list) do + allowlist = MapSet.new(@member_fields_allowlist) + + field_list + |> Enum.filter(fn field -> is_binary(field) and MapSet.member?(allowlist, field) end) + |> Enum.uniq() + end + + defp filter_valid_uuids(id_list) when is_list(id_list) do + id_list + |> Enum.filter(fn id -> + is_binary(id) and match?({:ok, _}, Ecto.UUID.cast(id)) + end) + |> Enum.uniq() + end +end diff --git a/lib/mv/membership/member_export_sort.ex b/lib/mv/membership/member_export_sort.ex new file mode 100644 index 0000000..324fb75 --- /dev/null +++ b/lib/mv/membership/member_export_sort.ex @@ -0,0 +1,44 @@ +defmodule Mv.Membership.MemberExportSort do + @moduledoc """ + Type-stable sort keys for CSV export custom-field sorting. + + Used only by `MvWeb.MemberExportController` when sorting members by a custom field + after load. Nil values sort last in ascending order and first in descending order. + String and email comparison is case-insensitive. + """ + @doc """ + Returns a comparable sort key for (value_type, value). + + - Nil: rank 1 so that in asc order nil sorts last, in desc nil sorts first. + - date: chronological (ISO8601 string). + - boolean: false < true (0 < 1). + - integer: numerical order. + - string / email: case-insensitive (downcased). + + Handles Ash.Union in value; value_type is the custom field's value_type atom. + """ + @spec custom_field_sort_key(:string | :integer | :boolean | :date | :email, term()) :: + {0 | 1, term()} + def custom_field_sort_key(_value_type, nil), do: {1, nil} + + def custom_field_sort_key(value_type, %Ash.Union{value: value, type: _type}) do + custom_field_sort_key(value_type, value) + end + + def custom_field_sort_key(:date, %Date{} = d), do: {0, Date.to_iso8601(d)} + def custom_field_sort_key(:boolean, true), do: {0, 1} + def custom_field_sort_key(:boolean, false), do: {0, 0} + def custom_field_sort_key(:integer, v) when is_integer(v), do: {0, v} + def custom_field_sort_key(:string, v) when is_binary(v), do: {0, String.downcase(v)} + def custom_field_sort_key(:email, v) when is_binary(v), do: {0, String.downcase(v)} + def custom_field_sort_key(_value_type, v), do: {0, to_string(v)} + + @doc """ + Returns true if key_a should sort before key_b for the given order. + + "asc" -> nil last; "desc" -> nil first. No reverse of list needed. + """ + @spec key_lt({0 | 1, term()}, {0 | 1, term()}, String.t()) :: boolean() + def key_lt(key_a, key_b, "asc"), do: key_a < key_b + def key_lt(key_a, key_b, "desc"), do: key_b < key_a +end diff --git a/lib/mv/membership/members_csv.ex b/lib/mv/membership/members_csv.ex index 6eab399..a0fd463 100644 --- a/lib/mv/membership/members_csv.ex +++ b/lib/mv/membership/members_csv.ex @@ -2,9 +2,11 @@ defmodule Mv.Membership.MembersCSV do @moduledoc """ Exports members to CSV (RFC 4180) as iodata. - Uses NimbleCSV.RFC4180 for encoding. Member fields are formatted as strings; - custom field values use the same formatting logic as the member overview (neutral formatter). - Column order for custom fields follows the key order of the `custom_fields_by_id` map. + Uses a column-based API: `export(members, columns)` where each column has + `header` (display string, e.g. from Web layer), `kind` (:member_field | :custom_field | :computed), + and `key` (member attribute name, custom_field id, or computed key). Custom field columns + include a `custom_field` struct for value formatting. Domain code does not use Gettext; + headers and computed values come from the caller (e.g. controller). """ alias Mv.Membership.CustomFieldValueFormatter alias NimbleCSV.RFC4180 @@ -12,57 +14,82 @@ defmodule Mv.Membership.MembersCSV do @doc """ Exports a list of members to CSV iodata. - - `members` - List of member structs (with optional `custom_field_values` loaded) - - `member_fields` - List of member field names (strings, e.g. `["first_name", "email"]`) - - `custom_fields_by_id` - Map of custom_field_id => %CustomField{}. Key order defines column order. + - `members` - List of member structs or maps (with optional `custom_field_values` loaded) + - `columns` - List of column specs: `%{header: String.t(), kind: :member_field | :custom_field | :computed, key: term()}` + For `:custom_field`, also pass `custom_field: %CustomField{}`. Header is used as-is (localized by caller). Returns iodata suitable for `IO.iodata_to_binary/1` or sending as response body. + RFC 4180 escaping and formula-injection safe_cell are applied. """ - @spec export( - [struct()], - [String.t()], - %{optional(String.t() | Ecto.UUID.t()) => struct()} - ) :: iodata() - def export(members, member_fields, custom_fields_by_id) when is_list(members) do - custom_entries = custom_field_entries(custom_fields_by_id) - header = build_header(member_fields, custom_entries) - rows = Enum.map(members, &build_row(&1, member_fields, custom_entries)) + @spec export([struct() | map()], [map()]) :: iodata() + def export(members, columns) when is_list(members) do + header = build_header(columns) + rows = Enum.map(members, fn member -> build_row(member, columns) end) RFC4180.dump_to_iodata([header | rows]) end - defp custom_field_entries(by_id) when is_map(by_id) do - Enum.map(by_id, fn {id, cf} -> {to_string(id), cf} end) + defp build_header(columns) do + columns + |> Enum.map(fn col -> col.header end) + |> Enum.map(&safe_cell/1) end - defp build_header(member_fields, custom_entries) do - member_headers = member_fields - custom_headers = Enum.map(custom_entries, fn {_id, cf} -> cf.name end) - member_headers ++ custom_headers + defp build_row(member, columns) do + columns + |> Enum.map(fn col -> cell_value(member, col) end) + |> Enum.map(&safe_cell/1) end - defp build_row(member, member_fields, custom_entries) do - member_cells = Enum.map(member_fields, &format_member_field(member, &1)) - - custom_cells = - Enum.map(custom_entries, fn {id, cf} -> format_custom_field(member, id, cf) end) - - member_cells ++ custom_cells - end - - defp format_member_field(member, field_name) do - key = member_field_key(field_name) - value = Map.get(member, key) + defp cell_value(member, %{kind: :member_field, key: key}) do + key_atom = key_to_atom(key) + value = Map.get(member, key_atom) format_member_value(value) end - defp member_field_key(field_name) when is_binary(field_name) do + defp cell_value(member, %{kind: :custom_field, key: id, custom_field: cf}) do + cfv = get_cfv_by_id(member, id) + + if cfv, + do: CustomFieldValueFormatter.format_custom_field_value(cfv.value, cf), + else: "" + end + + defp cell_value(member, %{kind: :computed, key: key}) do + value = Map.get(member, key_to_atom(key)) + if is_binary(value), do: value, else: "" + end + + defp key_to_atom(k) when is_atom(k), do: k + + defp key_to_atom(k) when is_binary(k) do try do - String.to_existing_atom(field_name) + String.to_existing_atom(k) rescue - ArgumentError -> field_name + ArgumentError -> k end end + defp get_cfv_by_id(member, id) do + values = + case Map.get(member, :custom_field_values) do + v when is_list(v) -> v + _ -> [] + end + + id_str = to_string(id) + + Enum.find(values, fn cfv -> + to_string(cfv.custom_field_id) == id_str or + (Map.get(cfv, :custom_field) && to_string(cfv.custom_field.id) == id_str) + end) + end + + @doc false + @spec safe_cell(String.t()) :: String.t() + def safe_cell(s) when is_binary(s) do + if String.starts_with?(s, ["=", "+", "-", "@", "\t"]), do: "'" <> s, else: s + end + defp format_member_value(nil), do: "" defp format_member_value(true), do: "true" defp format_member_value(false), do: "false" @@ -70,22 +97,4 @@ 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_custom_field(member, custom_field_id, custom_field) do - cfv = find_custom_field_value(member, custom_field_id) - - if cfv, - do: CustomFieldValueFormatter.format_custom_field_value(cfv.value, custom_field), - else: "" - end - - defp find_custom_field_value(member, custom_field_id) do - values = Map.get(member, :custom_field_values) || [] - id_str = to_string(custom_field_id) - - Enum.find(values, fn cfv -> - to_string(cfv.custom_field_id) == id_str or - (Map.get(cfv, :custom_field) && to_string(cfv.custom_field.id) == id_str) - end) - end end diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 45bcae0..e74020c 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -178,7 +178,8 @@ defmodule MvWeb.CoreComponents do aria-haspopup="menu" aria-expanded={@open} aria-controls={@id} - class="btn" + aria-label={@button_label} + class="btn focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-base-content/20" phx-click="toggle_dropdown" phx-target={@phx_target} data-testid="dropdown-button" @@ -232,11 +233,12 @@ defmodule MvWeb.CoreComponents do