Compare commits

..

6 commits

Author SHA1 Message Date
36e57b24be Merge branch 'main' into feature/export_csv
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-06 08:02:05 +01:00
8e387d8e17 tests: update tests
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-05 15:03:36 +01:00
9b9e7ec995 fix: sorting and filter for export 2026-02-05 15:03:25 +01:00
e7d63b9b0a fix linting
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-04 16:55:24 +01:00
b429a4dbb6 test: adds tests
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-04 16:43:12 +01:00
c82f4b7fd7 feat: add csv export
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-04 16:40:41 +01:00
24 changed files with 2224 additions and 761 deletions

View file

@ -166,8 +166,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
@ -223,6 +224,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,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

View file

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

View file

@ -0,0 +1,100 @@
defmodule Mv.Membership.MembersCSV do
@moduledoc """
Exports members to CSV (RFC 4180) as iodata.
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
@doc """
Exports a list of members to CSV iodata.
- `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() | 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 build_header(columns) do
columns
|> Enum.map(fn col -> col.header end)
|> Enum.map(&safe_cell/1)
end
defp build_row(member, columns) do
columns
|> Enum.map(fn col -> cell_value(member, col) end)
|> Enum.map(&safe_cell/1)
end
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 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(k)
rescue
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"
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)
end

View file

@ -179,7 +179,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"
@ -233,11 +234,12 @@ defmodule MvWeb.CoreComponents do
<button
type="button"
role={if @checkboxes, do: "menuitemcheckbox", else: "menuitem"}
aria-label={item.label}
aria-checked={
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
}
tabindex="0"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
phx-click="select_item"
phx-keydown="select_item"
phx-key="Enter"
@ -248,7 +250,7 @@ defmodule MvWeb.CoreComponents do
<input
type="checkbox"
checked={Map.get(@selected, item.value, true)}
class="checkbox checkbox-sm checkbox-primary"
class="checkbox checkbox-sm checkbox-primary pointer-events-none"
tabindex="-1"
aria-hidden="true"
/>

View file

@ -0,0 +1,433 @@
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")),
computed_fields: filter_existing_atoms(extract_list(params, "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)
}
end
defp filter_existing_atoms(list) when is_list(list) do
list
|> Enum.filter(&is_binary/1)
|> Enum.filter(fn name ->
try do
_ = String.to_existing_atom(name)
true
rescue
ArgumentError -> false
end
end)
|> Enum.uniq()
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
# FIX: Wenn nach einem Custom Field sortiert wird, muss dieses Feld geladen werden,
# auch wenn es nicht exportiert wird (sonst kann Export nicht korrekt sortieren).
parsed =
parsed
|> ensure_sort_custom_field_loaded()
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
columns = build_columns(conn, parsed, custom_fields_by_id)
csv_iodata = MembersCSV.export(members, columns)
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 ensure_sort_custom_field_loaded(%{custom_field_ids: ids, sort_field: sort_field} = parsed) do
case extract_sort_custom_field_id(sort_field) do
nil ->
parsed
id ->
%{parsed | custom_field_ids: Enum.uniq([id | ids])}
end
end
defp extract_sort_custom_field_id(field) when is_binary(field) do
if String.starts_with?(field, @custom_field_prefix) do
String.trim_leading(field, @custom_field_prefix)
else
nil
end
end
defp extract_sort_custom_field_id(_), do: nil
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 ->
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)
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
# selected export: filtert die Menge, aber die Sortierung muss trotzdem wie in der Tabelle angewandt werden
Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
else
query
|> apply_search_export(parsed.query)
end
# FIX: Sortierung IMMER anwenden (auch bei selected_ids)
{query, sort_after_load} = maybe_sort_export(query, parsed.sort_field, parsed.sort_order)
case Ash.read(query, actor: actor) do
{:ok, members} ->
members =
if sort_after_load do
sort_members_by_custom_field_export(
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_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
cond do
custom_field_sort?(field) ->
# Custom field sort → in-memory nach dem Read (wie Tabelle)
{query, true}
true ->
field_atom = String.to_existing_atom(field)
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
else
{query, false}
end
end
rescue
ArgumentError -> {query, false}
end
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
# ------------------------------------------------------------------
# Custom field sorting (match member table behavior)
# ------------------------------------------------------------------
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
order = order || "asc"
id_str = String.trim_leading(field, @custom_field_prefix)
custom_field =
Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
if is_nil(custom_field) do
members
else
# Match table:
# 1) values first, empty last
# 2) sort only values
# 3) for desc, reverse only the values-part
{with_values, without_values} =
Enum.split_with(members, fn member ->
has_non_empty_custom_field_value?(member, custom_field)
end)
sorted_with_values =
Enum.sort_by(with_values, fn member ->
member
|> find_cfv(custom_field)
|> case do
nil -> nil
cfv -> extract_sort_value(cfv.value, custom_field.value_type)
end
end)
sorted_with_values =
if order == "desc", do: Enum.reverse(sorted_with_values), else: sorted_with_values
sorted_with_values ++ without_values
end
end
defp has_non_empty_custom_field_value?(member, custom_field) do
case find_cfv(member, custom_field) do
nil ->
false
cfv ->
extracted = extract_sort_value(cfv.value, custom_field.value_type)
not empty_value?(extracted, custom_field.value_type)
end
end
defp empty_value?(nil, _type), do: true
defp empty_value?(value, type) when type in [:string, :email] and is_binary(value) do
String.trim(value) == ""
end
defp empty_value?(_value, _type), do: false
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 build_columns(conn, parsed, custom_fields_by_id) do
member_cols =
Enum.map(parsed.member_fields, fn field ->
%{
header: member_field_header(conn, field),
kind: :member_field,
key: field
}
end)
computed_cols =
Enum.map(parsed.computed_fields, fn key ->
%{
header: computed_field_header(conn, key),
kind: :computed,
key: key
}
end)
custom_cols =
parsed.custom_field_ids
|> Enum.map(fn id ->
cf = Map.get(custom_fields_by_id, id) || Map.get(custom_fields_by_id, to_string(id))
if cf do
%{
header: custom_field_header(conn, cf),
kind: :custom_field,
key: to_string(id),
custom_field: cf
}
else
nil
end
end)
|> Enum.reject(&is_nil/1)
member_cols ++ computed_cols ++ custom_cols
end
# --- headers: hier solltest du idealerweise eure bestehenden "display name" Helfer verwenden ---
defp member_field_header(_conn, field) when is_binary(field) do
# TODO: hier euren bestehenden display-name helper verwenden (wie Tabelle)
humanize_field(field)
end
defp computed_field_header(_conn, key) when is_binary(key) do
# TODO: display-name helper für computed fields verwenden
humanize_field(key)
end
defp custom_field_header(_conn, cf) do
# Custom fields: meist ist cf.name bereits der Display Name
cf.name
end
defp humanize_field(str) do
str
|> String.replace("_", " ")
|> String.capitalize()
end
defp extract_sort_value(%Ash.Union{value: value, type: type}, _),
do: extract_sort_value(value, type)
defp extract_sort_value(nil, _), do: nil
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)
end

View file

@ -41,13 +41,16 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
# RENDER
# ---------------------------------------------------------------------------
# Export-only alias; must not appear in dropdown (canonical UI key is membership_fee_status).
@payment_status_value "payment_status"
@impl true
def render(assigns) do
all_fields = assigns.all_fields || []
custom_fields = assigns.custom_fields || []
all_items =
Enum.map(extract_member_field_keys(all_fields), fn field ->
(Enum.map(extract_member_field_keys(all_fields), fn field ->
%{
value: field_to_string(field),
label: format_field_label(field)
@ -58,7 +61,9 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
value: field,
label: format_custom_field_label(field, custom_fields)
}
end)
end))
|> Enum.reject(fn item -> item.value == @payment_status_value end)
|> Enum.uniq_by(fn item -> item.value end)
assigns = assign(assigns, :all_items, all_items)

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

File diff suppressed because it is too large Load diff

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"
@ -282,6 +296,7 @@
</:col>
<:col
:let={member}
:if={:membership_fee_status in @member_fields_visible}
label={gettext("Membership Fee Status")}
>
<%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(

View file

@ -18,10 +18,25 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
1. User-specific selection (from URL/Session/Cookie)
2. Global settings (from database)
3. Default (all fields visible)
## Pseudo Member Fields
Overview-only fields that are not in `Mv.Constants.member_fields()` (e.g. computed/UI-only).
They appear in the field dropdown and in `member_fields_visible` but are not domain attributes.
"""
alias Mv.Membership.Helpers.VisibilityConfig
# Single UI key for "Membership Fee Status"; only this appears in the dropdown.
@pseudo_member_fields [:membership_fee_status]
# Export/API may accept this as alias; must not appear in the UI options list.
@export_only_alias :payment_status
defp overview_member_fields do
Mv.Constants.member_fields() ++ @pseudo_member_fields
end
@doc """
Gets all available fields for selection.
@ -39,7 +54,10 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
"""
@spec get_all_available_fields([struct()]) :: [atom() | String.t()]
def get_all_available_fields(custom_fields) do
member_fields = Mv.Constants.member_fields()
member_fields =
overview_member_fields()
|> Enum.reject(fn field -> field == @export_only_alias end)
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
member_fields ++ custom_field_names
@ -115,6 +133,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
field_selection
|> Enum.filter(fn {_field, visible} -> visible end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|> Enum.uniq()
end
def get_visible_fields(_), do: []
@ -132,7 +151,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
"""
@spec get_visible_member_fields(%{String.t() => boolean()}) :: [atom()]
def get_visible_member_fields(field_selection) when is_map(field_selection) do
member_fields = Mv.Constants.member_fields()
member_fields = overview_member_fields()
field_selection
|> Enum.filter(fn {field_string, visible} ->
@ -140,10 +159,61 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
visible && field_atom in member_fields
end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|> Enum.uniq()
end
def get_visible_member_fields(_), do: []
@doc """
Returns the list of computed (UI-only) member field atoms.
These fields are not in the database; they must not be used for Ash query
select/sort. Use this to filter sort options and validate sort_field.
"""
@spec computed_member_fields() :: [atom()]
def computed_member_fields, do: @pseudo_member_fields
@doc """
Visible member fields that are real DB attributes (from `Mv.Constants.member_fields()`).
Use for query select/sort. Not for rendering column visibility (use
`get_visible_member_fields/1` for that).
"""
@spec get_visible_member_fields_db(%{String.t() => boolean()}) :: [atom()]
def get_visible_member_fields_db(field_selection) when is_map(field_selection) do
db_fields = MapSet.new(Mv.Constants.member_fields())
field_selection
|> Enum.filter(fn {field_string, visible} ->
field_atom = to_field_identifier(field_string)
visible && field_atom in db_fields
end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|> Enum.uniq()
end
def get_visible_member_fields_db(_), do: []
@doc """
Visible member fields that are computed/UI-only (e.g. membership_fee_status).
Use for rendering; do not use for query select or sort.
"""
@spec get_visible_member_fields_computed(%{String.t() => boolean()}) :: [atom()]
def get_visible_member_fields_computed(field_selection) when is_map(field_selection) do
computed_set = MapSet.new(@pseudo_member_fields)
field_selection
|> Enum.filter(fn {field_string, visible} ->
field_atom = to_field_identifier(field_string)
visible && field_atom in computed_set
end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|> Enum.uniq()
end
def get_visible_member_fields_computed(_), do: []
@doc """
Gets visible custom fields from field selection.
@ -176,20 +246,24 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
Map.merge(member_visibility, custom_field_visibility)
end
# Gets member field visibility from settings
# Gets member field visibility from settings (domain fields from settings, pseudo fields default true)
defp get_member_field_visibility_from_settings(settings) do
visibility_config =
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
member_fields = Mv.Constants.member_fields()
domain_fields = Mv.Constants.member_fields()
Enum.reduce(member_fields, %{}, fn field, acc ->
domain_map =
Enum.reduce(domain_fields, %{}, fn field, acc ->
field_string = Atom.to_string(field)
# exit_date defaults to false (hidden), all other fields default to true
default_visibility = if field == :exit_date, do: false, else: true
show_in_overview = Map.get(visibility_config, field, default_visibility)
Map.put(acc, field_string, show_in_overview)
end)
Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc ->
Map.put(acc, Atom.to_string(field), true)
end)
end
# Gets custom field visibility (all custom fields with show_in_overview=true are visible)
@ -203,16 +277,20 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
end)
end
# Converts field string to atom (for member fields) or keeps as string (for custom fields)
# Converts field string to atom (for member fields) or keeps as string (for custom fields).
# Maps export-only alias to canonical UI key so only one option controls the column.
defp to_field_identifier(field_string) when is_binary(field_string) do
if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do
field_string
else
atom =
try do
String.to_existing_atom(field_string)
rescue
ArgumentError -> field_string
end
if atom == @export_only_alias, do: :membership_fee_status, else: atom
end
end

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

@ -28,6 +28,7 @@ defmodule MvWeb.Translations.MemberFields do
def label(:house_number), do: gettext("House Number")
def label(:postal_code), do: gettext("Postal Code")
def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date")
def label(:membership_fee_status), do: gettext("Membership Fee Status")
# Fallback for unknown fields
def label(field) do

View file

@ -2318,52 +2318,20 @@ 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/membership/member/validations/email_change_permission.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Only administrators or the linked user can change the email for members linked to users"
msgstr "Nur Administrator*innen oder die verknüpfte Benutzer*in können die E-Mail von Mitgliedern ändern, die mit Benutzer*innen verknüpft sind."
#~ #: lib/mv_web/live/global_settings_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Custom Fields in CSV Import"
#~ msgstr "Benutzerdefinierte Felder"
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Select role..."
msgstr "Keine auswählen"
#~ #: lib/mv_web/live/global_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Failed to prepare CSV import: %{error}"
#~ msgstr "Das Vorbereiten des CSV Imports ist gescheitert: %{error}"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "You are not allowed to perform this action."
msgstr "Du hast keine Berechtigung, diese Aktion auszuführen."
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Select a membership fee type"
msgstr "Mitgliedsbeitragstyp auswählen"
#: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Linked"
msgstr "Verknüpft"
#: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "OIDC"
msgstr "OIDC"
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Not linked"
msgstr "Nicht verknüpft"
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "SSO / OIDC user"
msgstr "SSO-/OIDC-Benutzer*in"
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
msgstr "Dieser*e Benutzer*in ist per SSO (Single Sign-On) angebunden. Ein hier gesetztes oder geändertes Passwort betrifft nur die Anmeldung mit E-Mail und Passwort in dieser Anwendung. Es ändert nicht das Passwort beim Identity-Provider (z. B. Authentik). Zum Ändern des SSO-Passworts nutzen Sie den Identity-Provider oder die IT Ihrer Organisation."
#~ #: lib/mv_web/live/global_settings_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Individual data fields must be created in Mila before importing. Use the field name as the CSV column header. Unknown custom field columns will be ignored with a warning."
#~ msgstr "Individuelle Datenfelder müssen in Mila erstellt werden, bevor sie importiert werden können. Verwende den Namen des Datenfeldes als CSV-Spaltenüberschrift. Unbekannte Spaltenüberschriften werden mit einer Warnung ignoriert."
#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#~ #, elixir-autogen, elixir-format

View file

@ -2319,6 +2319,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
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/membership/member/validations/email_change_permission.ex
#, elixir-autogen, elixir-format
msgid "Only administrators or the linked user can change the email for members linked to users"

View file

@ -2319,52 +2319,35 @@ 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/membership/member/validations/email_change_permission.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Only administrators or the linked user can change the email for members linked to users"
msgstr "Only administrators or the linked user can change the email for members linked to users"
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Select role..."
msgid "Export members to CSV"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "You are not allowed to perform this action."
msgid "Export to CSV"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Select a membership fee type"
msgstr ""
#: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Linked"
msgstr ""
#: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "OIDC"
msgid "all"
msgstr ""
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Not linked"
msgstr ""
#~ #: lib/mv_web/live/global_settings_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Custom Fields in CSV Import"
#~ msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "SSO / OIDC user"
msgstr ""
#~ #: lib/mv_web/live/global_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Failed to prepare CSV import: %{error}"
#~ msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
msgstr ""
#~ #: lib/mv_web/live/global_settings_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Individual data fields must be created in Mila before importing. Use the field name as the CSV column header. Unknown custom field columns will be ignored with a warning."
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#~ #, elixir-autogen, elixir-format

View file

@ -0,0 +1,90 @@
defmodule Mv.Membership.MemberExportSortTest do
use ExUnit.Case, async: true
alias Mv.Membership.MemberExportSort
describe "custom_field_sort_key/2" do
test "nil has rank 1 (sorts last in asc, first in desc)" do
assert MemberExportSort.custom_field_sort_key(:string, nil) == {1, nil}
assert MemberExportSort.custom_field_sort_key(:date, nil) == {1, nil}
end
test "date: chronological key (ISO8601 string)" do
earlier = ~D[2023-01-15]
later = ~D[2024-06-01]
assert MemberExportSort.custom_field_sort_key(:date, earlier) == {0, "2023-01-15"}
assert MemberExportSort.custom_field_sort_key(:date, later) == {0, "2024-06-01"}
assert {0, "2023-01-15"} < {0, "2024-06-01"}
end
test "date + nil: nil sorts after any date in asc" do
key_date = MemberExportSort.custom_field_sort_key(:date, ~D[2024-01-01])
key_nil = MemberExportSort.custom_field_sort_key(:date, nil)
assert key_date == {0, "2024-01-01"}
assert key_nil == {1, nil}
assert key_date < key_nil
end
test "boolean: false < true" do
key_f = MemberExportSort.custom_field_sort_key(:boolean, false)
key_t = MemberExportSort.custom_field_sort_key(:boolean, true)
assert key_f == {0, 0}
assert key_t == {0, 1}
assert key_f < key_t
end
test "boolean + nil: nil sorts after false and true in asc" do
key_f = MemberExportSort.custom_field_sort_key(:boolean, false)
key_t = MemberExportSort.custom_field_sort_key(:boolean, true)
key_nil = MemberExportSort.custom_field_sort_key(:boolean, nil)
assert key_f < key_nil and key_t < key_nil
end
test "integer: numerical key" do
assert MemberExportSort.custom_field_sort_key(:integer, 10) == {0, 10}
assert MemberExportSort.custom_field_sort_key(:integer, -5) == {0, -5}
assert MemberExportSort.custom_field_sort_key(:integer, 0) == {0, 0}
assert {0, -5} < {0, 0} and {0, 0} < {0, 10}
end
test "string: case-insensitive key (downcased)" do
key_a = MemberExportSort.custom_field_sort_key(:string, "Anna")
key_b = MemberExportSort.custom_field_sort_key(:string, "bert")
assert key_a == {0, "anna"}
assert key_b == {0, "bert"}
assert key_a < key_b
end
test "email: case-insensitive key" do
assert MemberExportSort.custom_field_sort_key(:email, "User@Example.com") ==
{0, "user@example.com"}
end
test "Ash.Union value is unwrapped" do
union = %Ash.Union{value: ~D[2024-01-01], type: :date}
assert MemberExportSort.custom_field_sort_key(:date, union) == {0, "2024-01-01"}
end
end
describe "key_lt/3" do
test "asc: smaller key first, nil last" do
k_nil = {1, nil}
k_early = {0, "2023-01-01"}
k_late = {0, "2024-01-01"}
refute MemberExportSort.key_lt(k_nil, k_early, "asc")
refute MemberExportSort.key_lt(k_nil, k_late, "asc")
assert MemberExportSort.key_lt(k_early, k_late, "asc")
assert MemberExportSort.key_lt(k_early, k_nil, "asc")
end
test "desc: larger key first, nil first" do
k_nil = {1, nil}
k_early = {0, "2023-01-01"}
k_late = {0, "2024-01-01"}
assert MemberExportSort.key_lt(k_nil, k_early, "desc")
assert MemberExportSort.key_lt(k_nil, k_late, "desc")
assert MemberExportSort.key_lt(k_late, k_early, "desc")
refute MemberExportSort.key_lt(k_early, k_nil, "desc")
end
end
end

View file

@ -0,0 +1,293 @@
defmodule Mv.Membership.MembersCSVTest do
use ExUnit.Case, async: true
alias Mv.Membership.MembersCSV
describe "export/2" do
test "returns CSV with header and one data row (member fields only)" do
member = %{first_name: "Jane", email: "jane@example.com"}
columns = [
%{header: "First Name", kind: :member_field, key: "first_name"},
%{header: "Email", kind: :member_field, key: "email"}
]
iodata = MembersCSV.export([member], columns)
csv = IO.iodata_to_binary(iodata)
assert csv =~ "First Name"
assert csv =~ "Email"
assert csv =~ "Jane"
assert csv =~ "jane@example.com"
lines = String.split(csv, "\n", trim: true)
assert length(lines) == 2
end
test "header uses display labels not raw field names (regression guard)" do
member = %{first_name: "Jane", email: "jane@example.com"}
columns = [
%{header: "First Name", kind: :member_field, key: "first_name"},
%{header: "Email", kind: :member_field, key: "email"}
]
iodata = MembersCSV.export([member], columns)
csv = IO.iodata_to_binary(iodata)
header_line = csv |> String.split("\n", trim: true) |> hd()
assert header_line =~ "First Name"
assert header_line =~ "Email"
refute header_line =~ "first_name"
refute header_line =~ "email"
end
test "escapes cell containing comma (RFC 4180 quoted)" do
member = %{first_name: "Doe, John", email: "john@example.com"}
columns = [
%{header: "First Name", kind: :member_field, key: "first_name"},
%{header: "Email", kind: :member_field, key: "email"}
]
iodata = MembersCSV.export([member], columns)
csv = IO.iodata_to_binary(iodata)
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"}
columns = [
%{header: "First Name", kind: :member_field, key: "first_name"},
%{header: "Email", kind: :member_field, key: "email"}
]
iodata = MembersCSV.export([member], columns)
csv = IO.iodata_to_binary(iodata)
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]}
columns = [
%{header: "First Name", kind: :member_field, key: "first_name"},
%{header: "Email", kind: :member_field, key: "email"},
%{header: "Join Date", kind: :member_field, key: "join_date"}
]
iodata = MembersCSV.export([member], columns)
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"}
columns = [
%{header: "First Name", kind: :member_field, key: "first_name"},
%{header: "Last Name", kind: :member_field, key: "last_name"},
%{header: "Email", kind: :member_field, key: "email"}
]
iodata = MembersCSV.export([member], columns)
csv = IO.iodata_to_binary(iodata)
assert csv =~ "First Name"
assert csv =~ "Only"
assert csv =~ "x@y.com"
assert csv =~ "Only,,x@y"
end
test "custom field column uses header and formats value" do
custom_cf = %{id: "cf-1", name: "Active", value_type: :boolean}
columns = [
%{header: "First Name", kind: :member_field, key: "first_name"},
%{header: "Email", kind: :member_field, key: "email"},
%{header: "Active", kind: :custom_field, key: "cf-1", custom_field: custom_cf}
]
member = %{
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], columns)
csv = IO.iodata_to_binary(iodata)
assert csv =~ "Active"
assert csv =~ "Yes"
end
test "custom field uses display_name when present, else name" do
custom_cf = %{id: "cf-a", name: "FieldA", value_type: :string}
columns = [
%{header: "First Name", kind: :member_field, key: "first_name"},
%{
header: "Display Label",
kind: :custom_field,
key: "cf-a",
custom_field: Map.put(custom_cf, :display_name, "Display Label")
}
]
member = %{
first_name: "X",
email: "x@x.com",
custom_field_values: [
%{custom_field_id: "cf-a", value: "only_a", custom_field: custom_cf}
]
}
iodata = MembersCSV.export([member], columns)
csv = IO.iodata_to_binary(iodata)
assert csv =~ "Display Label"
assert csv =~ "only_a"
end
test "missing custom field value yields empty cell" do
cf1 = %{id: "cf-a", name: "FieldA", value_type: :string}
cf2 = %{id: "cf-b", name: "FieldB", value_type: :string}
columns = [
%{header: "First Name", kind: :member_field, key: "first_name"},
%{header: "Email", kind: :member_field, key: "email"},
%{header: "FieldA", kind: :custom_field, key: "cf-a", custom_field: cf1},
%{header: "FieldB", kind: :custom_field, key: "cf-b", custom_field: cf2}
]
member = %{
first_name: "X",
email: "x@x.com",
custom_field_values: [%{custom_field_id: "cf-a", value: "only_a", custom_field: cf1}]
}
iodata = MembersCSV.export([member], columns)
csv = IO.iodata_to_binary(iodata)
assert csv =~ "First Name,Email,FieldA,FieldB"
assert csv =~ "only_a"
assert csv =~ "X,x@x.com,only_a,"
end
test "computed column exports membership fee status label" do
columns = [
%{header: "First Name", kind: :member_field, key: "first_name"},
%{header: "Email", kind: :member_field, key: "email"},
%{header: "Membership Fee Status", kind: :computed, key: :membership_fee_status}
]
member = %{first_name: "M", email: "m@m.com", membership_fee_status: "Paid"}
iodata = MembersCSV.export([member], columns)
csv = IO.iodata_to_binary(iodata)
assert csv =~ "Membership Fee Status"
assert csv =~ "Paid"
assert csv =~ "M,m@m.com,Paid"
end
test "computed column with payment_status key exports same value (alias)" do
columns = [
%{header: "First Name", kind: :member_field, key: "first_name"},
%{header: "Membership Fee Status", kind: :computed, key: :payment_status}
]
member = %{first_name: "X", payment_status: "Unpaid"}
iodata = MembersCSV.export([member], columns)
csv = IO.iodata_to_binary(iodata)
assert csv =~ "Membership Fee Status"
assert csv =~ "Unpaid"
assert csv =~ "X,Unpaid"
end
test "CSV injection: formula-like and dangerous prefixes are escaped with apostrophe" do
member = %{
first_name: "=SUM(A1:A10)",
last_name: "+1",
email: "@cmd|evil"
}
custom_cf = %{id: "cf-1", name: "Note", value_type: :string}
columns = [
%{header: "First Name", kind: :member_field, key: "first_name"},
%{header: "Last Name", kind: :member_field, key: "last_name"},
%{header: "Email", kind: :member_field, key: "email"},
%{header: "Note", kind: :custom_field, key: "cf-1", custom_field: custom_cf}
]
member_with_cf =
Map.put(member, :custom_field_values, [
%{custom_field_id: "cf-1", value: "normal text", custom_field: custom_cf}
])
iodata = MembersCSV.export([member_with_cf], columns)
csv = IO.iodata_to_binary(iodata)
assert csv =~ "'=SUM(A1:A10)"
assert csv =~ "'+1"
assert csv =~ "'@cmd|evil"
assert csv =~ "normal text"
refute csv =~ ",'normal text"
end
test "CSV injection: minus and tab prefix are escaped" do
member = %{first_name: "-2", last_name: "\tleading", email: "safe@x.com"}
columns = [
%{header: "First Name", kind: :member_field, key: "first_name"},
%{header: "Last Name", kind: :member_field, key: "last_name"},
%{header: "Email", kind: :member_field, key: "email"}
]
iodata = MembersCSV.export([member], columns)
csv = IO.iodata_to_binary(iodata)
assert csv =~ "'-2"
assert csv =~ "'\tleading"
assert csv =~ "safe@x.com"
end
test "column order is preserved (headers and values)" do
cf1 = %{id: "a", name: "Custom1", value_type: :string}
cf2 = %{id: "b", name: "Custom2", value_type: :string}
columns = [
%{header: "First Name", kind: :member_field, key: "first_name"},
%{header: "Email", kind: :member_field, key: "email"},
%{header: "Custom2", kind: :custom_field, key: "b", custom_field: cf2},
%{header: "Custom1", kind: :custom_field, key: "a", custom_field: cf1}
]
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], columns)
csv = IO.iodata_to_binary(iodata)
assert csv =~ "First Name,Email,Custom2,Custom1"
assert csv =~ "M,m@m.com,v2,v1"
end
end
end

View file

@ -15,19 +15,19 @@ defmodule MvWeb.Components.SearchBarComponentTest do
{:ok, view, _html} = live(conn, "/members")
# simulate search input and check that other members are not listed
html =
_html =
view
|> element("form[role=search]")
|> render_submit(%{"query" => "Friedrich"})
refute html =~ "Greta"
refute has_element?(view, "input[data-testid='search-input'][value='Greta']")
html =
_html =
view
|> element("form[role=search]")
|> render_submit(%{"query" => "Greta"})
refute html =~ "Friedrich"
refute has_element?(view, "input[data-testid='search-input'][value='Friedrich']")
end
end
end

View file

@ -0,0 +1,243 @@
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 (headers are localized labels)
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 (headers are localized labels)
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
test "export includes membership_fee_status column when requested", %{
conn: conn,
member1: m1
} do
payload = %{
"selected_ids" => [m1.id],
"member_fields" => ["first_name", "membership_fee_status"],
"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 =~ "Membership Fee Status"
assert body =~ "Alice"
end
test "export with payment_status alias: header shows Membership Fee Status", %{
conn: conn,
member1: m1
} do
payload = %{
"selected_ids" => [m1.id],
"member_fields" => ["first_name", "payment_status"],
"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 =~ "Membership Fee Status"
assert body =~ "Alice"
end
test "export with show_current_cycle: membership fee status column exists stably", %{
conn: conn,
member1: _m1,
member2: _m2,
member3: _m3
} do
payload = %{
"selected_ids" => [],
"member_fields" => ["first_name", "email", "membership_fee_status"],
"custom_field_ids" => [],
"query" => nil,
"sort_field" => nil,
"sort_order" => nil,
"show_current_cycle" => true
}
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)
assert length(lines) >= 4
header = hd(lines)
assert header =~ "First Name"
assert header =~ "Email"
assert header =~ "Membership Fee Status"
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

@ -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,34 +46,30 @@ 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 English", %{conn: conn} do
conn = conn_with_oidc_user(conn)
test "shows translated title and button text by locale", %{conn: conn} do
locales = [
{"de", "Mitglieder", "Speichern",
fn c -> Plug.Test.init_test_session(c, locale: "de") end},
{"en", "Members", "Save",
fn c ->
Gettext.put_locale(MvWeb.Gettext, "en")
{:ok, _view, html} = live(conn, "/members")
# Expected English title
assert html =~ "Members"
end
c
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
for {_locale, expected_title, expected_button, set_locale} <- locales do
base = conn_with_oidc_user(conn) |> set_locale.()
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"
{:ok, _view, index_html} = live(base, "/members")
assert index_html =~ expected_title
base_form = conn_with_oidc_user(conn) |> set_locale.()
{:ok, _view, form_html} = live(base_form, "/members/new")
assert form_html =~ expected_button
end
end
test "shows translated flash message after creating a member in German", %{conn: conn} do
@ -116,8 +112,10 @@ defmodule MvWeb.MemberLive.IndexTest do
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 +198,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 +225,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 +253,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 +522,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 +825,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 +834,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 +1809,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)