Compare commits
6 commits
main
...
feature/ex
| Author | SHA1 | Date | |
|---|---|---|---|
| 36e57b24be | |||
| 8e387d8e17 | |||
| 9b9e7ec995 | |||
| e7d63b9b0a | |||
| b429a4dbb6 | |||
| c82f4b7fd7 |
24 changed files with 2224 additions and 761 deletions
|
|
@ -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",
|
||||
|
|
|
|||
55
lib/mv/membership/custom_field_value_formatter.ex
Normal file
55
lib/mv/membership/custom_field_value_formatter.ex
Normal 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
|
||||
344
lib/mv/membership/member_export.ex
Normal file
344
lib/mv/membership/member_export.ex
Normal 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
|
||||
44
lib/mv/membership/member_export_sort.ex
Normal file
44
lib/mv/membership/member_export_sort.ex
Normal 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
|
||||
100
lib/mv/membership/members_csv.ex
Normal file
100
lib/mv/membership/members_csv.ex
Normal 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
|
||||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
433
lib/mv_web/controllers/member_export_controller.ex
Normal file
433
lib/mv_web/controllers/member_export_controller.ex
Normal 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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
90
test/mv/membership/member_export_sort_test.exs
Normal file
90
test/mv/membership/member_export_sort_test.exs
Normal 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
|
||||
293
test/mv/membership/members_csv_test.exs
Normal file
293
test/mv/membership/members_csv_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
243
test/mv_web/controllers/member_export_controller_test.exs
Normal file
243
test/mv_web/controllers/member_export_controller_test.exs
Normal 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
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue