WIP: Implements CSV export closes #285 #408

Draft
carla wants to merge 3 commits from feature/export_csv into main
17 changed files with 960 additions and 67 deletions

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,297 @@
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.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()
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])
query
|> Ash.read(actor: actor)
|> handle_custom_fields_read_result(custom_field_ids)
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
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

View file

@ -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(),

View file

@ -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

View file

@ -2,6 +2,20 @@
<.header>
{gettext("Members")}
<:actions>
<form method="post" action={~p"/members/export.csv"} target="_blank" class="inline">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<input type="hidden" name="payload" value={@export_payload_json} />
<button
type="submit"
class="btn btn-secondary gap-2"
aria-label={gettext("Export members to CSV")}
>
<.icon name="hero-arrow-down-tray" />
{gettext("Export to CSV")} ({if @selected_count == 0,
do: gettext("all"),
else: @selected_count})
</button>
</form>
<.button
class="secondary"
id="copy-emails-btn"

View file

@ -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

View file

@ -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"

View file

@ -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 ""

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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/<label[^>]*>.*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")

View file

@ -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"})

View file

@ -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()

View file

@ -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)