Merge pull request 'Implements CSV export closes #285' (#408) from feature/export_csv into main
Some checks failed
continuous-integration/drone/push Build is failing

Reviewed-on: #408
This commit is contained in:
carla 2026-02-09 15:17:49 +01:00
commit 496e2e438f
31 changed files with 3563 additions and 1792 deletions

View file

@ -152,7 +152,9 @@ lib/
│ │ ├── membership_fee_settings_live.ex # Membership fee settings │ │ ├── membership_fee_settings_live.ex # Membership fee settings
│ │ ├── global_settings_live.ex # Global settings │ │ ├── global_settings_live.ex # Global settings
│ │ ├── group_live/ # Group management LiveViews │ │ ├── group_live/ # Group management LiveViews
│ │ ├── import_export_live.ex # CSV import/export LiveView │ │ ├── import_export_live.ex # CSV import/export LiveView (mount/events/glue only)
│ │ ├── import_export_live/ # Import/Export UI components
│ │ │ └── components.ex # custom_fields_notice, template_links, import_form, progress, results
│ │ └── contribution_type_live/ # Contribution types (mock-up) │ │ └── contribution_type_live/ # Contribution types (mock-up)
│ ├── auth_overrides.ex # AshAuthentication overrides │ ├── auth_overrides.ex # AshAuthentication overrides
│ ├── endpoint.ex # Phoenix endpoint │ ├── endpoint.ex # Phoenix endpoint

View file

@ -696,11 +696,14 @@ lib/
│ └── membership/ │ └── membership/
│ └── import/ │ └── import/
│ ├── member_csv.ex # prepare + process_chunk │ ├── member_csv.ex # prepare + process_chunk
│ ├── import_runner.ex # orchestration: file read, progress merge, chunk process, error format
│ ├── csv_parser.ex # delimiter detection + parsing + BOM handling │ ├── csv_parser.ex # delimiter detection + parsing + BOM handling
│ └── header_mapper.ex # normalization + header mapping │ └── header_mapper.ex # normalization + header mapping
└── mv_web/ └── mv_web/
└── live/ └── live/
└── global_settings_live.ex # add import section + LV message loop ├── import_export_live.ex # mount / handle_event / handle_info + glue only
└── import_export_live/
└── components.ex # UI: custom_fields_notice, template_links, import_form, import_progress, import_results
priv/ priv/
└── static/ └── static/

View file

@ -166,8 +166,9 @@ defmodule Mv.Authorization.PermissionSets do
"/users/:id", "/users/:id",
"/users/:id/edit", "/users/:id/edit",
"/users/:id/show/edit", "/users/:id/show/edit",
# Member list # Member list and CSV export
"/members", "/members",
"/members/export.csv",
# Member detail # Member detail
"/members/:id", "/members/:id",
# Custom field values overview # Custom field values overview
@ -223,6 +224,7 @@ defmodule Mv.Authorization.PermissionSets do
"/users/:id/edit", "/users/:id/edit",
"/users/:id/show/edit", "/users/:id/show/edit",
"/members", "/members",
"/members/export.csv",
# Create member # Create member
"/members/new", "/members/new",
"/members/:id", "/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,170 @@
defmodule Mv.Membership.Import.ImportRunner do
@moduledoc """
Orchestrates CSV member import: file reading, progress tracking, chunk processing,
and error formatting. Used by `MvWeb.ImportExportLive` to keep LiveView thin.
This module does not depend on Phoenix or LiveView. It provides pure functions for
progress/merge and side-effectful helpers (read_file_entry, process_chunk) that
are called from the LiveView or from tasks started by it.
"""
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership.Import.MemberCSV
@default_max_errors 50
@doc """
Reads file content from a Phoenix LiveView upload entry (path).
Used as the callback for `consume_uploaded_entries/3`. Returns `{:ok, content}` or
`{:error, reason}` with a user-friendly string.
"""
@spec read_file_entry(map(), map()) :: {:ok, String.t()} | {:error, String.t()}
def read_file_entry(%{path: path}, _entry) do
case File.read(path) do
{:ok, content} ->
{:ok, content}
{:error, reason} when is_atom(reason) ->
{:error, :file.format_error(reason)}
{:error, %File.Error{reason: reason}} ->
{:error, :file.format_error(reason)}
{:error, reason} ->
{:error, Exception.message(reason)}
end
end
@doc """
Normalizes the result of `consume_uploaded_entries/3` into `{:ok, content}` or `{:error, reason}`.
Handles both the standard `[{:ok, content}]` and test helpers that may return `[content]`.
"""
@spec parse_consume_result(list()) :: {:ok, String.t()} | {:error, String.t()}
def parse_consume_result(raw) do
case raw do
[{:ok, content}] when is_binary(content) -> {:ok, content}
[content] when is_binary(content) -> {:ok, content}
[{:error, reason}] -> {:error, gettext("Failed to read file: %{reason}", reason: reason)}
[] -> {:error, gettext("No file was uploaded")}
_other -> {:error, gettext("Failed to read uploaded file: unexpected format")}
end
end
@doc """
Builds the initial progress map from a prepared import_state.
"""
@spec initial_progress(map(), keyword()) :: map()
def initial_progress(import_state, opts \\ []) do
_max_errors = Keyword.get(opts, :max_errors, @default_max_errors)
total = length(import_state.chunks)
%{
inserted: 0,
failed: 0,
errors: [],
warnings: import_state.warnings || [],
status: :running,
current_chunk: 0,
total_chunks: total,
errors_truncated?: false
}
end
@doc """
Merges a chunk result into the current progress and returns updated progress.
Caps errors at `max_errors` (default 50). Sets `status` to `:done` when all chunks
have been processed.
"""
@spec merge_progress(map(), map(), non_neg_integer(), keyword()) :: map()
def merge_progress(progress, chunk_result, current_chunk_idx, opts \\ []) do
max_errors = Keyword.get(opts, :max_errors, @default_max_errors)
all_errors = progress.errors ++ chunk_result.errors
new_errors = Enum.take(all_errors, max_errors)
errors_truncated? = length(all_errors) > max_errors
new_warnings = progress.warnings ++ Map.get(chunk_result, :warnings, [])
chunks_processed = current_chunk_idx + 1
new_status = if chunks_processed >= progress.total_chunks, do: :done, else: :running
%{
inserted: progress.inserted + chunk_result.inserted,
failed: progress.failed + chunk_result.failed,
errors: new_errors,
warnings: new_warnings,
status: new_status,
current_chunk: chunks_processed,
total_chunks: progress.total_chunks,
errors_truncated?: errors_truncated? || Map.get(chunk_result, :errors_truncated?, false)
}
end
@doc """
Returns the next action after processing a chunk: send the next chunk index or done.
"""
@spec next_chunk_action(non_neg_integer(), non_neg_integer()) ::
{:send_chunk, non_neg_integer()} | :done
def next_chunk_action(current_idx, total_chunks) do
next_idx = current_idx + 1
if next_idx < total_chunks, do: {:send_chunk, next_idx}, else: :done
end
@doc """
Processes one chunk (validate + create members), then sends `{:chunk_done, idx, result}`
or `{:chunk_error, idx, reason}` to `live_view_pid`.
Options: `:custom_field_lookup`, `:existing_error_count`, `:max_errors`, `:actor`.
"""
@spec process_chunk(
list(),
map(),
map(),
keyword(),
pid(),
non_neg_integer()
) :: :ok
def process_chunk(chunk, column_map, custom_field_map, opts, live_view_pid, idx) do
result =
try do
MemberCSV.process_chunk(chunk, column_map, custom_field_map, opts)
rescue
e -> {:error, Exception.message(e)}
catch
:exit, reason -> {:error, inspect(reason)}
:throw, reason -> {:error, inspect(reason)}
end
case result do
{:ok, chunk_result} -> send(live_view_pid, {:chunk_done, idx, chunk_result})
{:error, reason} -> send(live_view_pid, {:chunk_error, idx, reason})
end
:ok
end
@doc """
Returns a user-facing error message for chunk failures (invalid index, missing state,
or processing failure).
"""
@spec format_chunk_error(
:invalid_index | :missing_state | :processing_failed,
non_neg_integer(),
any()
) ::
String.t()
def format_chunk_error(:invalid_index, idx, _reason) do
gettext("Invalid chunk index: %{idx}", idx: idx)
end
def format_chunk_error(:missing_state, idx, _reason) do
gettext("Import state is missing. Cannot process chunk %{idx}.", idx: idx)
end
def format_chunk_error(:processing_failed, idx, reason) do
gettext("Failed to process chunk %{idx}: %{reason}", idx: idx, reason: inspect(reason))
end
end

View file

@ -0,0 +1,450 @@
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"]
@computed_export_fields ["membership_fee_status"]
@computed_insert_after "membership_fee_start_date"
@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 = build_custom_fields_by_id(custom_field_ids, custom_fields)
{:ok, by_id}
{:error, %Ash.Error.Forbidden{}} ->
{:error, :forbidden}
end
end
defp build_custom_fields_by_id(custom_field_ids, custom_fields) do
Enum.reduce(custom_field_ids, %{}, fn id, acc ->
find_and_add_custom_field(acc, id, custom_fields)
end)
end
defp find_and_add_custom_field(acc, id, custom_fields) do
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do
nil -> acc
cf -> Map.put(acc, id, cf)
end
end
defp build_column_specs(parsed, custom_fields_by_id) do
member_specs = build_member_column_specs(parsed)
custom_specs = build_custom_column_specs(parsed, custom_fields_by_id)
member_specs ++ custom_specs
end
defp build_member_column_specs(parsed) do
Enum.map(parsed.member_fields, fn f ->
build_single_member_spec(f, parsed.selectable_member_fields)
end)
|> Enum.reject(&is_nil/1)
end
defp build_single_member_spec(field, selectable_member_fields) do
if field in selectable_member_fields do
%{kind: :member_field, key: field}
else
build_computed_spec(field)
end
end
defp build_computed_spec(field) do
# only allow known computed export fields to avoid crashing on unknown atoms
if field in @computed_export_fields do
%{kind: :computed, key: String.to_existing_atom(field)}
else
# ignore unknown non-selectable fields defensively
nil
end
end
defp build_custom_column_specs(parsed, custom_fields_by_id) do
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)
end
defp load_members(actor, parsed, custom_fields_by_id) do
query = build_members_query(parsed, custom_fields_by_id)
case Ash.read(query, actor: actor) do
{:ok, members} ->
processed_members = process_loaded_members(members, parsed, custom_fields_by_id)
{:ok, processed_members}
{:error, %Ash.Error.Forbidden{}} ->
{:error, :forbidden}
end
end
defp build_members_query(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 != [] or
"membership_fee_status" in parsed.member_fields
query =
Member
|> Ash.Query.new()
|> Ash.Query.select(select_fields)
|> load_custom_field_values_query(custom_field_ids_union)
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
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
end
defp process_loaded_members(members, parsed, custom_fields_by_id) do
members
|> apply_post_load_filters(parsed, custom_fields_by_id)
|> apply_post_load_sorting(parsed, custom_fields_by_id)
|> add_computed_fields(parsed.computed_fields, parsed.show_current_cycle)
end
defp apply_post_load_filters(members, parsed, custom_fields_by_id) do
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
end
defp apply_post_load_sorting(members, parsed, custom_fields_by_id) do
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
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
defp add_computed_fields(members, computed_fields, show_current_cycle) do
computed_fields = computed_fields || []
if "membership_fee_status" in computed_fields do
Enum.map(members, fn member ->
status = MembershipFeeStatus.get_cycle_status_for_member(member, show_current_cycle)
# <= Atom rein
Map.put(member, :membership_fee_status, status)
end)
else
members
end
end
# 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
# DB fields come from "member_fields"
raw_member_fields = extract_list(params, "member_fields")
member_fields = filter_allowed_member_fields(raw_member_fields)
# computed fields can come from "computed_fields" (new payload) OR legacy inclusion in member_fields
computed_fields =
(extract_list(params, "computed_fields") ++ member_fields)
|> normalize_computed_fields()
|> Enum.filter(&(&1 in @computed_export_fields))
|> Enum.uniq()
# selectable DB fields: only real domain member fields, ordered like the table
selectable_member_fields =
member_fields
|> Enum.filter(&(&1 in @domain_member_field_strings))
|> order_member_fields_like_table()
# final member_fields list (used for column specs order): table order + computed inserted
ordered_member_fields =
selectable_member_fields
|> insert_computed_fields_like_table(computed_fields)
%{
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
member_fields: ordered_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 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) and 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
defp order_member_fields_like_table(fields) when is_list(fields) do
table_order = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
table_order |> Enum.filter(&(&1 in fields))
end
defp insert_computed_fields_like_table(db_fields_ordered, computed_fields) do
# Insert membership_fee_status right after membership_fee_start_date (if both selected),
# otherwise append at the end of DB fields.
computed_fields = computed_fields || []
db_with_insert =
Enum.flat_map(db_fields_ordered, fn f ->
if f == @computed_insert_after and "membership_fee_status" in computed_fields do
[f, "membership_fee_status"]
else
[f]
end
end)
remaining =
computed_fields
|> Enum.reject(&(&1 in db_with_insert))
db_with_insert ++ remaining
end
defp normalize_computed_fields(fields) when is_list(fields) do
fields
|> Enum.filter(&is_binary/1)
|> Enum.map(fn
"payment_status" -> "membership_fee_status"
other -> other
end)
end
defp normalize_computed_fields(_), do: []
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-haspopup="menu"
aria-expanded={@open} aria-expanded={@open}
aria-controls={@id} 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-click="toggle_dropdown"
phx-target={@phx_target} phx-target={@phx_target}
data-testid="dropdown-button" data-testid="dropdown-button"
@ -233,11 +234,12 @@ defmodule MvWeb.CoreComponents do
<button <button
type="button" type="button"
role={if @checkboxes, do: "menuitemcheckbox", else: "menuitem"} role={if @checkboxes, do: "menuitemcheckbox", else: "menuitem"}
aria-label={item.label}
aria-checked={ aria-checked={
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
} }
tabindex="0" 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-click="select_item"
phx-keydown="select_item" phx-keydown="select_item"
phx-key="Enter" phx-key="Enter"
@ -248,7 +250,7 @@ defmodule MvWeb.CoreComponents do
<input <input
type="checkbox" type="checkbox"
checked={Map.get(@selected, item.value, true)} checked={Map.get(@selected, item.value, true)}
class="checkbox checkbox-sm checkbox-primary" class="checkbox checkbox-sm checkbox-primary pointer-events-none"
tabindex="-1" tabindex="-1"
aria-hidden="true" aria-hidden="true"
/> />

View file

@ -0,0 +1,519 @@
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
alias MvWeb.Translations.MemberFields
alias MvWeb.MemberLive.Index.MembershipFeeStatus
use Gettext, backend: MvWeb.Gettext
@member_fields_allowlist Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
@computed_export_fields ["membership_fee_status"]
@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
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 ++ 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),
show_current_cycle: extract_boolean(params, "show_current_cycle")
}
end
defp split_member_fields(member_fields) do
domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
selectable = Enum.filter(member_fields, fn f -> f in domain_fields end)
computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end)
{selectable, computed}
end
defp extract_boolean(params, key) do
case Map.get(params, key) do
true -> true
"true" -> true
_ -> false
end
end
defp filter_existing_atoms(list) when is_list(list) do
list
|> Enum.filter(fn name ->
is_binary(name) and atom_exists?(name)
end)
|> Enum.uniq()
end
defp atom_exists?(name) do
try do
_ = String.to_existing_atom(name)
true
rescue
ArgumentError -> false
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
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 ->
find_and_add_custom_field(acc, id, custom_fields)
end)
end
defp find_and_add_custom_field(acc, id, custom_fields) do
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do
nil -> acc
cf -> Map.put(acc, id, cf)
end
end
defp load_members_for_export(actor, parsed, custom_fields_by_id) do
select_fields = [:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1)
need_cycles =
parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields
query =
Member
|> Ash.Query.new()
|> Ash.Query.select(select_fields)
|> load_custom_field_values_query(parsed.custom_field_ids)
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
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
# Calculate membership_fee_status for computed fields
members = add_computed_fields(members, parsed.computed_fields, parsed.show_current_cycle)
{:ok, members}
{:error, %Ash.Error.Forbidden{}} ->
{:error, :forbidden}
end
end
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
# Adds computed field values to members (e.g. membership_fee_status)
defp add_computed_fields(members, computed_fields, show_current_cycle) do
if "membership_fee_status" in computed_fields do
Enum.map(members, fn member ->
status = MembershipFeeStatus.get_cycle_status_for_member(member, show_current_cycle)
status_string = format_membership_fee_status(status)
Map.put(member, :membership_fee_status, status_string)
end)
else
members
end
end
# Formats membership fee status as German string
defp format_membership_fee_status(:paid), do: gettext("paid")
defp format_membership_fee_status(:unpaid), do: gettext("unpaid")
defp format_membership_fee_status(:suspended), do: gettext("suspended")
defp format_membership_fee_status(nil), do: ""
defp load_custom_field_values_query(query, []), do: query
defp load_custom_field_values_query(query, custom_field_ids) do
cfv_query =
Mv.Membership.CustomFieldValue
|> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids))
|> Ash.Query.load(custom_field: [:id, :name, :value_type])
Ash.Query.load(query, custom_field_values: cfv_query)
end
defp apply_search_export(query, nil), do: query
defp apply_search_export(query, ""), do: query
defp apply_search_export(query, q) when is_binary(q) do
if String.trim(q) != "" do
Member.fuzzy_search(query, %{query: q})
else
query
end
end
defp maybe_sort_export(query, nil, _order), do: {query, false}
defp maybe_sort_export(query, _field, nil), do: {query, false}
defp maybe_sort_export(query, field, order) when is_binary(field) do
if custom_field_sort?(field) do
# Custom field sort → in-memory nach dem Read (wie Tabelle)
{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 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 ->
extract_member_sort_value(member, custom_field)
end)
sorted_with_values =
if order == "desc", do: Enum.reverse(sorted_with_values), else: sorted_with_values
sorted_with_values ++ without_values
end
end
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 extract_member_sort_value(member, custom_field) do
case find_cfv(member, custom_field) do
nil -> nil
cfv -> extract_sort_value(cfv.value, custom_field.value_type)
end
end
defp build_columns(conn, parsed, custom_fields_by_id) do
member_cols =
Enum.map(parsed.selectable_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: String.to_existing_atom(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: use MemberFields.label for translations ---
defp member_field_header(_conn, field) when is_binary(field) do
field
|> String.to_existing_atom()
|> MemberFields.label()
rescue
ArgumentError ->
# Fallback for unknown fields
humanize_field(field)
end
defp computed_field_header(_conn, key) when is_atom(key) do
# Map export-only alias to canonical UI key for translation
atom_key = if key == :payment_status, do: :membership_fee_status, else: key
MemberFields.label(atom_key)
end
defp computed_field_header(_conn, key) when is_binary(key) do
# Map export-only alias to canonical UI key for translation
atom_key =
case key do
"payment_status" -> :membership_fee_status
_ -> String.to_existing_atom(key)
end
MemberFields.label(atom_key)
rescue
ArgumentError ->
# Fallback for unknown computed fields
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.split()
|> Enum.map_join(" ", &String.capitalize/1)
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

@ -47,7 +47,7 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
custom_fields = assigns.custom_fields || [] custom_fields = assigns.custom_fields || []
all_items = 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), value: field_to_string(field),
label: format_field_label(field) label: format_field_label(field)
@ -58,7 +58,8 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
value: field, value: field,
label: format_custom_field_label(field, custom_fields) label: format_custom_field_label(field, custom_fields)
} }
end) end))
|> Enum.uniq_by(fn item -> item.value end)
assigns = assign(assigns, :all_items, all_items) assigns = assign(assigns, :all_items, all_items)

View file

@ -35,8 +35,10 @@ defmodule MvWeb.ImportExportLive do
alias Mv.Authorization.Actor alias Mv.Authorization.Actor
alias Mv.Config alias Mv.Config
alias Mv.Membership alias Mv.Membership
alias Mv.Membership.Import.ImportRunner
alias Mv.Membership.Import.MemberCSV alias Mv.Membership.Import.MemberCSV
alias MvWeb.Authorization alias MvWeb.Authorization
alias MvWeb.ImportExportLive.Components
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded} on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@ -98,11 +100,11 @@ defmodule MvWeb.ImportExportLive do
<%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %> <%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %>
<%!-- CSV Import Section --%> <%!-- CSV Import Section --%>
<.form_section title={gettext("Import Members (CSV)")}> <.form_section title={gettext("Import Members (CSV)")}>
{import_info_box(assigns)} <Components.custom_fields_notice {assigns} />
{template_links(assigns)} <Components.template_links {assigns} />
{import_form(assigns)} <Components.import_form {assigns} />
<%= if @import_status == :running or @import_status == :done do %> <%= if @import_status == :running or @import_status == :done or @import_status == :error do %>
{import_progress(assigns)} <Components.import_progress {assigns} />
<% end %> <% end %>
</.form_section> </.form_section>
@ -129,223 +131,6 @@ defmodule MvWeb.ImportExportLive do
""" """
end end
# Renders the info box explaining CSV import requirements
defp import_info_box(assigns) do
~H"""
<div role="note" class="alert alert-info mb-4">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<p class="text-sm mb-2">
{gettext(
"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."
)}
</p>
<p class="text-sm">
<.link
href={~p"/settings#custom_fields"}
class="link"
data-testid="custom-fields-link"
>
{gettext("Manage Member Data")}
</.link>
</p>
</div>
</div>
"""
end
# Renders template download links
defp template_links(assigns) do
~H"""
<div class="mb-4">
<p class="text-sm text-base-content/70 mb-2">
{gettext("Download CSV templates:")}
</p>
<ul class="list-disc list-inside space-y-1">
<li>
<.link
href={~p"/templates/member_import_en.csv"}
download="member_import_en.csv"
class="link link-primary"
>
{gettext("English Template")}
</.link>
</li>
<li>
<.link
href={~p"/templates/member_import_de.csv"}
download="member_import_de.csv"
class="link link-primary"
>
{gettext("German Template")}
</.link>
</li>
</ul>
</div>
"""
end
# Renders the CSV upload form
defp import_form(assigns) do
~H"""
<.form
id="csv-upload-form"
for={%{}}
multipart={true}
phx-change="validate_csv_upload"
phx-submit="start_import"
data-testid="csv-upload-form"
>
<div class="form-control">
<label for="csv_file" class="label">
<span class="label-text">
{gettext("CSV File")}
</span>
</label>
<.live_file_input
upload={@uploads.csv_file}
id="csv_file"
class="file-input file-input-bordered w-full"
aria-describedby="csv_file_help"
/>
<p class="label-text-alt mt-1" id="csv_file_help">
{gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)}
</p>
</div>
<.button
type="submit"
phx-disable-with={gettext("Starting import...")}
variant="primary"
disabled={import_button_disabled?(@import_status, @uploads.csv_file.entries)}
data-testid="start-import-button"
>
{gettext("Start Import")}
</.button>
</.form>
"""
end
# Renders import progress and results
defp import_progress(assigns) do
~H"""
<%= if @import_progress do %>
<div
role="status"
aria-live="polite"
class="mt-4"
data-testid="import-progress-container"
>
<%= if @import_progress.status == :running do %>
<p class="text-sm" data-testid="import-progress-text">
{gettext("Processing chunk %{current} of %{total}...",
current: @import_progress.current_chunk,
total: @import_progress.total_chunks
)}
</p>
<% end %>
<%= if @import_progress.status == :done do %>
{import_results(assigns)}
<% end %>
</div>
<% end %>
"""
end
# Renders import results summary, errors, and warnings
defp import_results(assigns) do
~H"""
<section class="space-y-4" data-testid="import-results-panel">
<h2 class="text-lg font-semibold">
{gettext("Import Results")}
</h2>
<div class="space-y-4">
<div>
<h3 class="text-sm font-semibold mb-2">
{gettext("Summary")}
</h3>
<div class="text-sm space-y-2">
<p>
<.icon
name="hero-check-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Successfully inserted: %{count} member(s)",
count: @import_progress.inserted
)}
</p>
<%= if @import_progress.failed > 0 do %>
<p>
<.icon
name="hero-exclamation-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Failed: %{count} row(s)", count: @import_progress.failed)}
</p>
<% end %>
<%= if @import_progress.errors_truncated? do %>
<p>
<.icon
name="hero-information-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Error list truncated to %{count} entries", count: @max_errors)}
</p>
<% end %>
</div>
</div>
<%= if length(@import_progress.errors) > 0 do %>
<div data-testid="import-error-list">
<h3 class="text-sm font-semibold mb-2">
<.icon
name="hero-exclamation-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Errors")}
</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<%= for error <- @import_progress.errors do %>
<li>
{gettext("Line %{line}: %{message}",
line: error.csv_line_number || "?",
message: error.message || gettext("Unknown error")
)}
<%= if error.field do %>
{gettext(" (Field: %{field})", field: error.field)}
<% end %>
</li>
<% end %>
</ul>
</div>
<% end %>
<%= if length(@import_progress.warnings) > 0 do %>
<div class="alert alert-warning" role="alert">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<h3 class="font-semibold mb-2">
{gettext("Warnings")}
</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<%= for warning <- @import_progress.warnings do %>
<li>{warning}</li>
<% end %>
</ul>
</div>
</div>
<% end %>
</div>
</section>
"""
end
@impl true @impl true
def handle_event("validate_csv_upload", _params, socket) do def handle_event("validate_csv_upload", _params, socket) do
{:noreply, socket} {:noreply, socket}
@ -436,7 +221,7 @@ defmodule MvWeb.ImportExportLive do
@spec start_import(Phoenix.LiveView.Socket.t(), map()) :: @spec start_import(Phoenix.LiveView.Socket.t(), map()) ::
{:noreply, Phoenix.LiveView.Socket.t()} {:noreply, Phoenix.LiveView.Socket.t()}
defp start_import(socket, import_state) do defp start_import(socket, import_state) do
progress = initialize_import_progress(import_state) progress = ImportRunner.initial_progress(import_state, max_errors: @max_errors)
socket = socket =
socket socket
@ -449,21 +234,6 @@ defmodule MvWeb.ImportExportLive do
{:noreply, socket} {:noreply, socket}
end end
# Initializes the import progress tracking structure with default values.
@spec initialize_import_progress(map()) :: map()
defp initialize_import_progress(import_state) do
%{
inserted: 0,
failed: 0,
errors: [],
warnings: import_state.warnings || [],
status: :running,
current_chunk: 0,
total_chunks: length(import_state.chunks),
errors_truncated?: false
}
end
# Formats error messages for user-friendly display. # Formats error messages for user-friendly display.
# #
# Handles various error types including Ash errors, maps with message fields, # Handles various error types including Ash errors, maps with message fields,
@ -557,52 +327,8 @@ defmodule MvWeb.ImportExportLive do
handle_chunk_error(socket, :processing_failed, idx, reason) handle_chunk_error(socket, :processing_failed, idx, reason)
end end
# Processes a chunk with error handling and sends result message to LiveView. # Starts async task to process a chunk of CSV rows (or runs synchronously in test sandbox).
# # Locale must be set in the process that runs the chunk (Gettext is process-local); see run_chunk_with_locale/7.
# Handles errors from MemberCSV.process_chunk and sends appropriate messages
# to the LiveView process for progress tracking.
@spec process_chunk_with_error_handling(
list(),
map(),
map(),
keyword(),
pid(),
non_neg_integer()
) :: :ok
defp process_chunk_with_error_handling(
chunk,
column_map,
custom_field_map,
opts,
live_view_pid,
idx
) do
result =
try do
MemberCSV.process_chunk(chunk, column_map, custom_field_map, opts)
rescue
e ->
{:error, Exception.message(e)}
catch
:exit, reason ->
{:error, inspect(reason)}
:throw, reason ->
{:error, inspect(reason)}
end
case result do
{:ok, chunk_result} ->
send(live_view_pid, {:chunk_done, idx, chunk_result})
{:error, reason} ->
send(live_view_pid, {:chunk_error, idx, reason})
end
end
# Starts async task to process a chunk of CSV rows.
#
# In tests (SQL sandbox mode), runs synchronously to avoid Ecto Sandbox issues.
@spec start_chunk_processing_task( @spec start_chunk_processing_task(
Phoenix.LiveView.Socket.t(), Phoenix.LiveView.Socket.t(),
map(), map(),
@ -613,8 +339,8 @@ defmodule MvWeb.ImportExportLive do
chunk = Enum.at(import_state.chunks, idx) chunk = Enum.at(import_state.chunks, idx)
actor = ensure_actor_loaded(socket) actor = ensure_actor_loaded(socket)
live_view_pid = self() live_view_pid = self()
locale = socket.assigns[:locale] || "de"
# Process chunk with existing error count for capping
opts = [ opts = [
custom_field_lookup: import_state.custom_field_lookup, custom_field_lookup: import_state.custom_field_lookup,
existing_error_count: length(progress.errors), existing_error_count: length(progress.errors),
@ -622,15 +348,9 @@ defmodule MvWeb.ImportExportLive do
actor: actor actor: actor
] ]
# Get locale from socket for translations in background tasks
locale = socket.assigns[:locale] || "de"
Gettext.put_locale(MvWeb.Gettext, locale)
if Config.sql_sandbox?() do if Config.sql_sandbox?() do
# Run synchronously in tests to avoid Ecto Sandbox issues with async tasks run_chunk_with_locale(
# In test mode, send the message - it will be processed when render() is called locale,
# in the test. The test helper wait_for_import_completion() handles message processing
process_chunk_with_error_handling(
chunk, chunk,
import_state.column_map, import_state.column_map,
import_state.custom_field_map, import_state.custom_field_map,
@ -639,14 +359,11 @@ defmodule MvWeb.ImportExportLive do
idx idx
) )
else else
# Start async task to process chunk in production Task.Supervisor.start_child(
# Use start_child for fire-and-forget: no monitor, no Task messages Mv.TaskSupervisor,
# We only use our own send/2 messages for communication fn ->
Task.Supervisor.start_child(Mv.TaskSupervisor, fn -> run_chunk_with_locale(
# Set locale in task process for translations locale,
Gettext.put_locale(MvWeb.Gettext, locale)
process_chunk_with_error_handling(
chunk, chunk,
import_state.column_map, import_state.column_map,
import_state.custom_field_map, import_state.custom_field_map,
@ -654,12 +371,28 @@ defmodule MvWeb.ImportExportLive do
live_view_pid, live_view_pid,
idx idx
) )
end) end
)
end end
{:noreply, socket} {:noreply, socket}
end end
# Sets Gettext locale in the current process, then processes the chunk.
# Must be called in the process that runs the chunk (sync: LiveView process; async: Task process).
defp run_chunk_with_locale(
locale,
chunk,
column_map,
custom_field_map,
opts,
live_view_pid,
idx
) do
Gettext.put_locale(MvWeb.Gettext, locale)
ImportRunner.process_chunk(chunk, column_map, custom_field_map, opts, live_view_pid, idx)
end
# Handles chunk processing result from async task and schedules the next chunk. # Handles chunk processing result from async task and schedules the next chunk.
@spec handle_chunk_result( @spec handle_chunk_result(
Phoenix.LiveView.Socket.t(), Phoenix.LiveView.Socket.t(),
@ -669,20 +402,29 @@ defmodule MvWeb.ImportExportLive do
map() map()
) :: {:noreply, Phoenix.LiveView.Socket.t()} ) :: {:noreply, Phoenix.LiveView.Socket.t()}
defp handle_chunk_result(socket, import_state, progress, idx, chunk_result) do defp handle_chunk_result(socket, import_state, progress, idx, chunk_result) do
# Merge progress new_progress =
new_progress = merge_progress(progress, chunk_result, idx) ImportRunner.merge_progress(progress, chunk_result, idx, max_errors: @max_errors)
socket = socket =
socket socket
|> assign(:import_progress, new_progress) |> assign(:import_progress, new_progress)
|> assign(:import_status, new_progress.status) |> assign(:import_status, new_progress.status)
|> maybe_send_next_chunk(idx, length(import_state.chunks))
# Schedule next chunk or mark as done
socket = schedule_next_chunk(socket, idx, length(import_state.chunks))
{:noreply, socket} {:noreply, socket}
end end
defp maybe_send_next_chunk(socket, current_idx, total_chunks) do
case ImportRunner.next_chunk_action(current_idx, total_chunks) do
{:send_chunk, next_idx} ->
send(self(), {:process_chunk, next_idx})
socket
:done ->
socket
end
end
# Handles chunk processing errors and updates socket with error status. # Handles chunk processing errors and updates socket with error status.
@spec handle_chunk_error( @spec handle_chunk_error(
Phoenix.LiveView.Socket.t(), Phoenix.LiveView.Socket.t(),
@ -691,129 +433,23 @@ defmodule MvWeb.ImportExportLive do
any() any()
) :: {:noreply, Phoenix.LiveView.Socket.t()} ) :: {:noreply, Phoenix.LiveView.Socket.t()}
defp handle_chunk_error(socket, error_type, idx, reason \\ nil) do defp handle_chunk_error(socket, error_type, idx, reason \\ nil) do
error_message = message = ImportRunner.format_chunk_error(error_type, idx, reason)
case error_type do
:invalid_index ->
gettext("Invalid chunk index: %{idx}", idx: idx)
:missing_state ->
gettext("Import state is missing. Cannot process chunk %{idx}.", idx: idx)
:processing_failed ->
gettext("Failed to process chunk %{idx}: %{reason}",
idx: idx,
reason: inspect(reason)
)
end
socket = socket =
socket socket
|> assign(:import_status, :error) |> assign(:import_status, :error)
|> put_flash(:error, error_message) |> put_flash(:error, message)
{:noreply, socket} {:noreply, socket}
end end
# Consumes uploaded CSV file entries and reads the file content. # Consumes uploaded CSV file entries and reads the file content.
#
# Returns the file content as a binary string or an error tuple.
@spec consume_and_read_csv(Phoenix.LiveView.Socket.t()) :: @spec consume_and_read_csv(Phoenix.LiveView.Socket.t()) ::
{:ok, String.t()} | {:error, String.t()} {:ok, String.t()} | {:error, String.t()}
defp consume_and_read_csv(socket) do defp consume_and_read_csv(socket) do
raw = consume_uploaded_entries(socket, :csv_file, &read_file_entry/2) raw = consume_uploaded_entries(socket, :csv_file, &ImportRunner.read_file_entry/2)
ImportRunner.parse_consume_result(raw)
case raw do
[{:ok, content}] when is_binary(content) ->
{:ok, content}
# Phoenix LiveView test (render_upload) can return raw content list when callback return is treated as value
[content] when is_binary(content) ->
{:ok, content}
[{:error, reason}] ->
{:error, gettext("Failed to read file: %{reason}", reason: reason)}
[] ->
{:error, gettext("No file was uploaded")}
_other ->
{:error, gettext("Failed to read uploaded file: unexpected format")}
end end
end
# Reads a single file entry from the uploaded path
@spec read_file_entry(map(), map()) :: {:ok, String.t()} | {:error, String.t()}
defp read_file_entry(%{path: path}, _entry) do
case File.read(path) do
{:ok, content} ->
{:ok, content}
{:error, reason} when is_atom(reason) ->
# POSIX error atoms (e.g., :enoent) need to be formatted
{:error, :file.format_error(reason)}
{:error, %File.Error{reason: reason}} ->
# File.Error struct with reason atom
{:error, :file.format_error(reason)}
{:error, reason} ->
# Fallback for other error types
{:error, Exception.message(reason)}
end
end
# Merges chunk processing results into the overall import progress.
#
# Handles error capping, warning merging, and status updates.
@spec merge_progress(map(), map(), non_neg_integer()) :: map()
defp merge_progress(progress, chunk_result, current_chunk_idx) do
# Merge errors with cap of @max_errors overall
all_errors = progress.errors ++ chunk_result.errors
new_errors = Enum.take(all_errors, @max_errors)
errors_truncated? = length(all_errors) > @max_errors
# Merge warnings (optional dedupe - simple append for now)
new_warnings = progress.warnings ++ Map.get(chunk_result, :warnings, [])
# Update status based on whether we're done
# current_chunk_idx is 0-based, so after processing chunk 0, we've processed 1 chunk
chunks_processed = current_chunk_idx + 1
new_status = if chunks_processed >= progress.total_chunks, do: :done, else: :running
%{
inserted: progress.inserted + chunk_result.inserted,
failed: progress.failed + chunk_result.failed,
errors: new_errors,
warnings: new_warnings,
status: new_status,
current_chunk: chunks_processed,
total_chunks: progress.total_chunks,
errors_truncated?: errors_truncated? || chunk_result.errors_truncated?
}
end
# Schedules the next chunk for processing or marks import as complete.
@spec schedule_next_chunk(Phoenix.LiveView.Socket.t(), non_neg_integer(), non_neg_integer()) ::
Phoenix.LiveView.Socket.t()
defp schedule_next_chunk(socket, current_idx, total_chunks) do
next_idx = current_idx + 1
if next_idx < total_chunks do
# Schedule next chunk
send(self(), {:process_chunk, next_idx})
socket
else
# All chunks processed - status already set to :done in merge_progress
socket
end
end
# Determines if the import button should be disabled based on import status and upload state
@spec import_button_disabled?(:idle | :running | :done | :error, [map()]) :: boolean()
defp import_button_disabled?(:running, _entries), do: true
defp import_button_disabled?(_status, []), do: true
defp import_button_disabled?(_status, [entry | _]) when not entry.done?, do: true
defp import_button_disabled?(_status, _entries), do: false
# Ensures the actor (user with role) is loaded from socket assigns. # Ensures the actor (user with role) is loaded from socket assigns.
# #

View file

@ -0,0 +1,272 @@
defmodule MvWeb.ImportExportLive.Components do
@moduledoc """
Function components for the Import/Export LiveView: import form, progress, results,
custom fields notice, and template links. Keeps the main LiveView focused on
mount/handle_event/handle_info and glue code.
"""
use Phoenix.Component
use Gettext, backend: MvWeb.Gettext
import MvWeb.CoreComponents
use Phoenix.VerifiedRoutes,
endpoint: MvWeb.Endpoint,
router: MvWeb.Router,
statics: MvWeb.static_paths()
@doc """
Renders the info box explaining that data fields must exist before import
and linking to Manage Member Data (custom fields).
"""
def custom_fields_notice(assigns) do
~H"""
<div role="note" class="alert alert-info mb-4">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<p class="text-sm mb-2">
{gettext(
"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."
)}
</p>
<p class="text-sm">
<.link
href={~p"/settings#custom_fields"}
class="link"
data-testid="custom-fields-link"
>
{gettext("Manage Member Data")}
</.link>
</p>
</div>
</div>
"""
end
@doc """
Renders download links for English and German CSV templates.
"""
def template_links(assigns) do
~H"""
<div class="mb-4">
<p class="text-sm text-base-content/70 mb-2">
{gettext("Download CSV templates:")}
</p>
<ul class="list-disc list-inside space-y-1">
<li>
<.link
href={~p"/templates/member_import_en.csv"}
download="member_import_en.csv"
class="link link-primary"
>
{gettext("English Template")}
</.link>
</li>
<li>
<.link
href={~p"/templates/member_import_de.csv"}
download="member_import_de.csv"
class="link link-primary"
>
{gettext("German Template")}
</.link>
</li>
</ul>
</div>
"""
end
@doc """
Renders the CSV file upload form and Start Import button.
"""
def import_form(assigns) do
~H"""
<.form
id="csv-upload-form"
for={%{}}
multipart={true}
phx-change="validate_csv_upload"
phx-submit="start_import"
data-testid="csv-upload-form"
>
<div class="form-control">
<label for="csv_file" class="label">
<span class="label-text">
{gettext("CSV File")}
</span>
</label>
<.live_file_input
upload={@uploads.csv_file}
id="csv_file"
class="file-input file-input-bordered w-full"
aria-describedby="csv_file_help"
/>
<p class="label-text-alt mt-1" id="csv_file_help">
{gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)}
</p>
</div>
<.button
type="submit"
phx-disable-with={gettext("Starting import...")}
variant="primary"
disabled={import_button_disabled?(@import_status, @uploads.csv_file.entries)}
data-testid="start-import-button"
>
{gettext("Start Import")}
</.button>
</.form>
"""
end
@doc """
Renders import progress text and, when done or aborted, the import results section.
"""
def import_progress(assigns) do
~H"""
<%= if @import_progress do %>
<div
role="status"
aria-live="polite"
aria-atomic="true"
class="mt-4"
data-testid="import-progress-container"
>
<%= if @import_progress.status == :running do %>
<p class="text-sm" data-testid="import-progress-text">
{gettext("Processing chunk %{current} of %{total}...",
current: @import_progress.current_chunk,
total: @import_progress.total_chunks
)}
</p>
<% end %>
<%= if @import_progress.status == :done or @import_status == :error do %>
<.import_results {assigns} />
<% end %>
</div>
<% end %>
"""
end
@doc """
Renders import results summary, error list, and warnings.
Shown when import is done or aborted (:error); heading reflects state.
"""
def import_results(assigns) do
~H"""
<section
class="space-y-4"
data-testid="import-results-panel"
aria-labelledby="import-results-heading"
>
<h2
id="import-results-heading"
class="text-lg font-semibold"
data-testid="import-results-heading"
>
<%= if @import_status == :error do %>
{gettext("Import aborted")}
<% else %>
{gettext("Import Results")}
<% end %>
</h2>
<div class="space-y-4">
<div data-testid="import-summary">
<h3 class="text-sm font-semibold mb-2">
{gettext("Summary")}
</h3>
<div class="text-sm space-y-2">
<p>
<.icon
name="hero-check-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Successfully inserted: %{count} member(s)",
count: @import_progress.inserted
)}
</p>
<%= if @import_progress.failed > 0 do %>
<p>
<.icon
name="hero-exclamation-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Failed: %{count} row(s)", count: @import_progress.failed)}
</p>
<% end %>
<%= if @import_progress.errors_truncated? do %>
<p>
<.icon
name="hero-information-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Error list truncated to %{count} entries", count: @max_errors)}
</p>
<% end %>
</div>
</div>
<%= if length(@import_progress.errors) > 0 do %>
<div
role="alert"
aria-live="assertive"
aria-atomic="true"
data-testid="import-error-list"
>
<h3 class="text-sm font-semibold mb-2">
<.icon
name="hero-exclamation-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Errors")}
</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<%= for error <- @import_progress.errors do %>
<li>
{gettext("Line %{line}: %{message}",
line: error.csv_line_number || "?",
message: error.message || gettext("Unknown error")
)}
<%= if error.field do %>
{gettext(" (Field: %{field})", field: error.field)}
<% end %>
</li>
<% end %>
</ul>
</div>
<% end %>
<%= if length(@import_progress.warnings) > 0 do %>
<div class="alert alert-warning" role="alert" data-testid="import-warnings">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<h3 class="font-semibold mb-2">
{gettext("Warnings")}
</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<%= for warning <- @import_progress.warnings do %>
<li>{warning}</li>
<% end %>
</ul>
</div>
</div>
<% end %>
</div>
</section>
"""
end
@doc """
Returns whether the Start Import button should be disabled.
"""
@spec import_button_disabled?(:idle | :running | :done | :error, [map()]) :: boolean()
def import_button_disabled?(:running, _entries), do: true
def import_button_disabled?(_status, []), do: true
def import_button_disabled?(_status, [entry | _]) when not entry.done?, do: true
def import_button_disabled?(_status, _entries), do: false
end

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,20 @@
<.header> <.header>
{gettext("Members")} {gettext("Members")}
<:actions> <: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 <.button
class="secondary" class="secondary"
id="copy-emails-btn" id="copy-emails-btn"
@ -282,6 +296,7 @@
</:col> </:col>
<:col <:col
:let={member} :let={member}
:if={:membership_fee_status in @member_fields_visible}
label={gettext("Membership Fee Status")} label={gettext("Membership Fee Status")}
> >
<%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge( <%= 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) 1. User-specific selection (from URL/Session/Cookie)
2. Global settings (from database) 2. Global settings (from database)
3. Default (all fields visible) 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 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 """ @doc """
Gets all available fields for selection. 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()] @spec get_all_available_fields([struct()]) :: [atom() | String.t()]
def get_all_available_fields(custom_fields) do 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}") custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
member_fields ++ custom_field_names member_fields ++ custom_field_names
@ -115,6 +133,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
field_selection field_selection
|> Enum.filter(fn {_field, visible} -> visible end) |> Enum.filter(fn {_field, visible} -> visible end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end) |> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|> Enum.uniq()
end end
def get_visible_fields(_), do: [] def get_visible_fields(_), do: []
@ -132,7 +151,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
""" """
@spec get_visible_member_fields(%{String.t() => boolean()}) :: [atom()] @spec get_visible_member_fields(%{String.t() => boolean()}) :: [atom()]
def get_visible_member_fields(field_selection) when is_map(field_selection) do 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 field_selection
|> Enum.filter(fn {field_string, visible} -> |> Enum.filter(fn {field_string, visible} ->
@ -140,10 +159,61 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
visible && field_atom in member_fields visible && field_atom in member_fields
end) end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end) |> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|> Enum.uniq()
end end
def get_visible_member_fields(_), do: [] 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 """ @doc """
Gets visible custom fields from field selection. Gets visible custom fields from field selection.
@ -176,20 +246,24 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
Map.merge(member_visibility, custom_field_visibility) Map.merge(member_visibility, custom_field_visibility)
end 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 defp get_member_field_visibility_from_settings(settings) do
visibility_config = visibility_config =
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{})) 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) 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 default_visibility = if field == :exit_date, do: false, else: true
show_in_overview = Map.get(visibility_config, field, default_visibility) show_in_overview = Map.get(visibility_config, field, default_visibility)
Map.put(acc, field_string, show_in_overview) Map.put(acc, field_string, show_in_overview)
end) end)
Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc ->
Map.put(acc, Atom.to_string(field), true)
end)
end end
# Gets custom field visibility (all custom fields with show_in_overview=true are visible) # 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)
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 defp to_field_identifier(field_string) when is_binary(field_string) do
if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do
field_string field_string
else else
atom =
try do try do
String.to_existing_atom(field_string) String.to_existing_atom(field_string)
rescue rescue
ArgumentError -> field_string ArgumentError -> field_string
end end
if atom == @export_only_alias, do: :membership_fee_status, else: atom
end end
end end

View file

@ -91,6 +91,7 @@ defmodule MvWeb.Router do
# Import/Export (Admin only) # Import/Export (Admin only)
live "/admin/import-export", ImportExportLive live "/admin/import-export", ImportExportLive
post "/members/export.csv", MemberExportController, :export
post "/set_locale", LocaleController, :set_locale post "/set_locale", LocaleController, :set_locale
end end

View file

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

View file

@ -1296,6 +1296,7 @@ msgid "Membership Fee Settings"
msgstr "Mitgliedsbeitragseinstellungen" msgstr "Mitgliedsbeitragseinstellungen"
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Membership Fee Status" msgid "Membership Fee Status"
msgstr "Mitgliedsbeitragsstatus" msgstr "Mitgliedsbeitragsstatus"
@ -1534,7 +1535,7 @@ msgstr "Mitgliedsbeitragsart löschen"
#: lib/mv_web/translations/member_fields.ex #: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Membership Fee Start Date" msgid "Membership Fee Start Date"
msgstr "Mitgliedsbeitragsstatus" msgstr "Startdatum Mitgliedsbeitrag"
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex #: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -1848,7 +1849,7 @@ msgstr "erstellt"
msgid "updated" msgid "updated"
msgstr "aktualisiert" msgstr "aktualisiert"
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Unknown error" msgid "Unknown error"
@ -1969,32 +1970,32 @@ msgstr "Bezahlstatus"
msgid "Reset" msgid "Reset"
msgstr "Zurücksetzen" msgstr "Zurücksetzen"
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid " (Field: %{field})" msgid " (Field: %{field})"
msgstr " (Datenfeld: %{field})" msgstr " (Datenfeld: %{field})"
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "CSV File" msgid "CSV File"
msgstr "CSV Datei" msgstr "CSV Datei"
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Download CSV templates:" msgid "Download CSV templates:"
msgstr "CSV Vorlagen herunterladen:" msgstr "CSV Vorlagen herunterladen:"
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "English Template" msgid "English Template"
msgstr "Englische Vorlage" msgstr "Englische Vorlage"
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Error list truncated to %{count} entries" msgid "Error list truncated to %{count} entries"
msgstr "Liste der Fehler auf %{count} Einträge reduziert" msgstr "Liste der Fehler auf %{count} Einträge reduziert"
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Errors" msgid "Errors"
msgstr "Fehler" msgstr "Fehler"
@ -2004,22 +2005,22 @@ msgstr "Fehler"
msgid "Failed to prepare CSV import: %{reason}" msgid "Failed to prepare CSV import: %{reason}"
msgstr "Das Vorbereiten des CSV Imports ist gescheitert: %{reason}" msgstr "Das Vorbereiten des CSV Imports ist gescheitert: %{reason}"
#: lib/mv_web/live/import_export_live.ex #: lib/mv/membership/import/import_runner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Failed to process chunk %{idx}: %{reason}" msgid "Failed to process chunk %{idx}: %{reason}"
msgstr "Das Importieren von %{idx} ist gescheitert: %{reason}" msgstr "Das Importieren von %{idx} ist gescheitert: %{reason}"
#: lib/mv_web/live/import_export_live.ex #: lib/mv/membership/import/import_runner.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Failed to read file: %{reason}" msgid "Failed to read file: %{reason}"
msgstr "Fehler beim Lesen der Datei: %{reason}" msgstr "Fehler beim Lesen der Datei: %{reason}"
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Failed: %{count} row(s)" msgid "Failed: %{count} row(s)"
msgstr "Fehlgeschlagen: %{count} Zeile(n)" msgstr "Fehlgeschlagen: %{count} Zeile(n)"
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "German Template" msgid "German Template"
msgstr "Deutsche Vorlage" msgstr "Deutsche Vorlage"
@ -2029,7 +2030,7 @@ msgstr "Deutsche Vorlage"
msgid "Import Members (CSV)" msgid "Import Members (CSV)"
msgstr "Mitglieder importieren (CSV)" msgstr "Mitglieder importieren (CSV)"
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Import Results" msgid "Import Results"
msgstr "Import-Ergebnisse" msgstr "Import-Ergebnisse"
@ -2039,22 +2040,22 @@ msgstr "Import-Ergebnisse"
msgid "Import is already running. Please wait for it to complete." msgid "Import is already running. Please wait for it to complete."
msgstr "Import läuft bereits. Bitte warte, bis er abgeschlossen ist." msgstr "Import läuft bereits. Bitte warte, bis er abgeschlossen ist."
#: lib/mv_web/live/import_export_live.ex #: lib/mv/membership/import/import_runner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Import state is missing. Cannot process chunk %{idx}." msgid "Import state is missing. Cannot process chunk %{idx}."
msgstr "Import-Status fehlt. Chunk %{idx} kann nicht verarbeitet werden." msgstr "Import-Status fehlt. Chunk %{idx} kann nicht verarbeitet werden."
#: lib/mv_web/live/import_export_live.ex #: lib/mv/membership/import/import_runner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Invalid chunk index: %{idx}" msgid "Invalid chunk index: %{idx}"
msgstr "Ungültiger Chunk-Index: %{idx}" msgstr "Ungültiger Chunk-Index: %{idx}"
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Line %{line}: %{message}" msgid "Line %{line}: %{message}"
msgstr "Zeile %{line}: %{message}" msgstr "Zeile %{line}: %{message}"
#: lib/mv_web/live/import_export_live.ex #: lib/mv/membership/import/import_runner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No file was uploaded" msgid "No file was uploaded"
msgstr "Es wurde keine Datei hochgeladen" msgstr "Es wurde keine Datei hochgeladen"
@ -2074,32 +2075,32 @@ msgstr "Bitte wähle eine CSV-Datei zum Importieren."
msgid "Please wait for the file upload to complete before starting the import." msgid "Please wait for the file upload to complete before starting the import."
msgstr "Bitte warte, bis der Datei-Upload abgeschlossen ist, bevor du den Import startest." msgstr "Bitte warte, bis der Datei-Upload abgeschlossen ist, bevor du den Import startest."
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Processing chunk %{current} of %{total}..." msgid "Processing chunk %{current} of %{total}..."
msgstr "Verarbeite Chunk %{current} von %{total}..." msgstr "Verarbeite Chunk %{current} von %{total}..."
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Start Import" msgid "Start Import"
msgstr "Import starten" msgstr "Import starten"
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Starting import..." msgid "Starting import..."
msgstr "Import wird gestartet..." msgstr "Import wird gestartet..."
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Successfully inserted: %{count} member(s)" msgid "Successfully inserted: %{count} member(s)"
msgstr "Erfolgreich eingefügt: %{count} Mitglied(er)" msgstr "Erfolgreich eingefügt: %{count} Mitglied(er)"
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Summary" msgid "Summary"
msgstr "Zusammenfassung" msgstr "Zusammenfassung"
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Warnings" msgid "Warnings"
msgstr "Warnungen" msgstr "Warnungen"
@ -2247,7 +2248,7 @@ msgstr "Nicht berechtigt."
msgid "Could not load data fields. Please check your permissions." msgid "Could not load data fields. Please check your permissions."
msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfe deine Berechtigungen." msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfe deine Berechtigungen."
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "CSV files only, maximum %{size} MB" msgid "CSV files only, maximum %{size} MB"
msgstr "Nur CSV Dateien, maximal %{size} MB" msgstr "Nur CSV Dateien, maximal %{size} MB"
@ -2287,7 +2288,7 @@ msgstr "Mitglieder importieren (CSV)"
msgid "Export functionality will be available in a future release." msgid "Export functionality will be available in a future release."
msgstr "Export-Funktionalität ist im nächsten release verfügbar." msgstr "Export-Funktionalität ist im nächsten release verfügbar."
#: lib/mv_web/live/import_export_live.ex #: lib/mv/membership/import/import_runner.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Failed to read uploaded file: unexpected format" msgid "Failed to read uploaded file: unexpected format"
msgstr "Fehler beim Lesen der hochgeladenen Datei" msgstr "Fehler beim Lesen der hochgeladenen Datei"
@ -2308,39 +2309,54 @@ msgstr "Import/Export"
msgid "You do not have permission to access this page." msgid "You do not have permission to access this page."
msgstr "Du hast keine Berechtigung, auf diese Seite zuzugreifen." msgstr "Du hast keine Berechtigung, auf diese Seite zuzugreifen."
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Manage Member Data" msgid "Manage Member Data"
msgstr "Mitgliederdaten verwalten" msgstr "Mitgliederdaten verwalten"
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
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." 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." 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 #: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Export members to CSV"
msgstr "Mitglieder importieren (CSV)"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Export to CSV"
msgstr "Nach CSV exportieren"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "all"
msgstr "alle"
#: 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" 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." msgstr "Nur Administrator*innen oder die verknüpften Nutzer*innen können die Email Adresse für Mitglieder verknüpfter Nutzer*innen ändern."
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Select role..." msgid "Select role..."
msgstr "Keine auswählen" msgstr "Rolle auswählen..."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "You are not allowed to perform this action." msgid "You are not allowed to perform this action."
msgstr "Du hast keine Berechtigung, diese Aktion auszuführen." msgstr "Du hast keine Berechtigungen diese Aktion auszuführen."
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format, fuzzy
msgid "Select a membership fee type" msgid "Select a membership fee type"
msgstr "Mitgliedsbeitragstyp auswählen" msgstr "Beitragsart auswählen"
#: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex #: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format, fuzzy
msgid "Linked" msgid "Linked"
msgstr "Verknüpft" msgstr "Verknüpft"
@ -2351,19 +2367,54 @@ msgid "OIDC"
msgstr "OIDC" msgstr "OIDC"
#: lib/mv_web/live/user_live/show.ex #: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format, fuzzy
msgid "Not linked" msgid "Not linked"
msgstr "Nichtverknüpft" msgstr "Nichtverknüpft"
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "SSO / OIDC user" msgid "SSO / OIDC user"
msgstr "SSO-/OIDC-Benutzer*in" msgstr "SSO / OIDC Nutzer*in"
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format
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." 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." msgstr "Diese*r Nutzer*in ist über SSO (Single Sign-On) verbunden. Ein hier festgelegtes oder geändertes Passwort wirkt sich nur auf die Anmeldung mit E-Mail-Adresse und Passwort in dieser Anwendung aus. Es ändert nicht das Passwort in Eurem Identitätsanbieter (z. B. Authentik). Um das SSO-Passwort zu ändern, wende dich an den Identitätsanbieter oder die IT-Abteilung Ihrer Organisation."
#: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format
msgid "Import aborted"
msgstr "Import abgebrochen"
#: lib/mv_web/controllers/member_export_controller.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "paid"
msgstr "Bezahlt"
#: lib/mv_web/controllers/member_export_controller.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "suspended"
msgstr "Pausiert"
#: lib/mv_web/controllers/member_export_controller.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "unpaid"
msgstr "Unbezahlt"
#~ #: 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/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/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 #~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format

View file

@ -1297,6 +1297,7 @@ msgid "Membership Fee Settings"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Membership Fee Status" msgid "Membership Fee Status"
msgstr "" msgstr ""
@ -1849,7 +1850,7 @@ msgstr ""
msgid "updated" msgid "updated"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Unknown error" msgid "Unknown error"
@ -1970,32 +1971,32 @@ msgstr ""
msgid "Reset" msgid "Reset"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid " (Field: %{field})" msgid " (Field: %{field})"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "CSV File" msgid "CSV File"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Download CSV templates:" msgid "Download CSV templates:"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "English Template" msgid "English Template"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Error list truncated to %{count} entries" msgid "Error list truncated to %{count} entries"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Errors" msgid "Errors"
msgstr "" msgstr ""
@ -2005,22 +2006,22 @@ msgstr ""
msgid "Failed to prepare CSV import: %{reason}" msgid "Failed to prepare CSV import: %{reason}"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv/membership/import/import_runner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Failed to process chunk %{idx}: %{reason}" msgid "Failed to process chunk %{idx}: %{reason}"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv/membership/import/import_runner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Failed to read file: %{reason}" msgid "Failed to read file: %{reason}"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Failed: %{count} row(s)" msgid "Failed: %{count} row(s)"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "German Template" msgid "German Template"
msgstr "" msgstr ""
@ -2030,7 +2031,7 @@ msgstr ""
msgid "Import Members (CSV)" msgid "Import Members (CSV)"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Import Results" msgid "Import Results"
msgstr "" msgstr ""
@ -2040,22 +2041,22 @@ msgstr ""
msgid "Import is already running. Please wait for it to complete." msgid "Import is already running. Please wait for it to complete."
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv/membership/import/import_runner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Import state is missing. Cannot process chunk %{idx}." msgid "Import state is missing. Cannot process chunk %{idx}."
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv/membership/import/import_runner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Invalid chunk index: %{idx}" msgid "Invalid chunk index: %{idx}"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Line %{line}: %{message}" msgid "Line %{line}: %{message}"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv/membership/import/import_runner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No file was uploaded" msgid "No file was uploaded"
msgstr "" msgstr ""
@ -2075,32 +2076,32 @@ msgstr ""
msgid "Please wait for the file upload to complete before starting the import." msgid "Please wait for the file upload to complete before starting the import."
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Processing chunk %{current} of %{total}..." msgid "Processing chunk %{current} of %{total}..."
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Start Import" msgid "Start Import"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Starting import..." msgid "Starting import..."
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Successfully inserted: %{count} member(s)" msgid "Successfully inserted: %{count} member(s)"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Summary" msgid "Summary"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Warnings" msgid "Warnings"
msgstr "" msgstr ""
@ -2248,7 +2249,7 @@ msgstr ""
msgid "Could not load data fields. Please check your permissions." msgid "Could not load data fields. Please check your permissions."
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "CSV files only, maximum %{size} MB" msgid "CSV files only, maximum %{size} MB"
msgstr "" msgstr ""
@ -2288,7 +2289,7 @@ msgstr ""
msgid "Export functionality will be available in a future release." msgid "Export functionality will be available in a future release."
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv/membership/import/import_runner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Failed to read uploaded file: unexpected format" msgid "Failed to read uploaded file: unexpected format"
msgstr "" msgstr ""
@ -2309,16 +2310,31 @@ msgstr ""
msgid "You do not have permission to access this page." msgid "You do not have permission to access this page."
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Manage Member Data" msgid "Manage Member Data"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning." 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 "" 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 #: lib/mv/membership/member/validations/email_change_permission.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Only administrators or the linked user can change the email for members linked to users" msgid "Only administrators or the linked user can change the email for members linked to users"
@ -2365,3 +2381,23 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
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." 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 "" msgstr ""
#: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format
msgid "Import aborted"
msgstr ""
#: lib/mv_web/controllers/member_export_controller.ex
#, elixir-autogen, elixir-format
msgid "paid"
msgstr ""
#: lib/mv_web/controllers/member_export_controller.ex
#, elixir-autogen, elixir-format
msgid "suspended"
msgstr ""
#: lib/mv_web/controllers/member_export_controller.ex
#, elixir-autogen, elixir-format
msgid "unpaid"
msgstr ""

View file

@ -1297,6 +1297,7 @@ msgid "Membership Fee Settings"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Membership Fee Status" msgid "Membership Fee Status"
msgstr "" msgstr ""
@ -1849,7 +1850,7 @@ msgstr ""
msgid "updated" msgid "updated"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Unknown error" msgid "Unknown error"
@ -1970,32 +1971,32 @@ msgstr ""
msgid "Reset" msgid "Reset"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid " (Field: %{field})" msgid " (Field: %{field})"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "CSV File" msgid "CSV File"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Download CSV templates:" msgid "Download CSV templates:"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "English Template" msgid "English Template"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Error list truncated to %{count} entries" msgid "Error list truncated to %{count} entries"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Errors" msgid "Errors"
msgstr "" msgstr ""
@ -2005,22 +2006,22 @@ msgstr ""
msgid "Failed to prepare CSV import: %{reason}" msgid "Failed to prepare CSV import: %{reason}"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv/membership/import/import_runner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Failed to process chunk %{idx}: %{reason}" msgid "Failed to process chunk %{idx}: %{reason}"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv/membership/import/import_runner.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Failed to read file: %{reason}" msgid "Failed to read file: %{reason}"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Failed: %{count} row(s)" msgid "Failed: %{count} row(s)"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "German Template" msgid "German Template"
msgstr "" msgstr ""
@ -2030,7 +2031,7 @@ msgstr ""
msgid "Import Members (CSV)" msgid "Import Members (CSV)"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Import Results" msgid "Import Results"
msgstr "" msgstr ""
@ -2040,22 +2041,22 @@ msgstr ""
msgid "Import is already running. Please wait for it to complete." msgid "Import is already running. Please wait for it to complete."
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv/membership/import/import_runner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Import state is missing. Cannot process chunk %{idx}." msgid "Import state is missing. Cannot process chunk %{idx}."
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv/membership/import/import_runner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Invalid chunk index: %{idx}" msgid "Invalid chunk index: %{idx}"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Line %{line}: %{message}" msgid "Line %{line}: %{message}"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv/membership/import/import_runner.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No file was uploaded" msgid "No file was uploaded"
msgstr "" msgstr ""
@ -2075,32 +2076,32 @@ msgstr ""
msgid "Please wait for the file upload to complete before starting the import." msgid "Please wait for the file upload to complete before starting the import."
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Processing chunk %{current} of %{total}..." msgid "Processing chunk %{current} of %{total}..."
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Start Import" msgid "Start Import"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Starting import..." msgid "Starting import..."
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Successfully inserted: %{count} member(s)" msgid "Successfully inserted: %{count} member(s)"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Summary" msgid "Summary"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Warnings" msgid "Warnings"
msgstr "" msgstr ""
@ -2248,7 +2249,7 @@ msgstr ""
msgid "Could not load data fields. Please check your permissions." msgid "Could not load data fields. Please check your permissions."
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "CSV files only, maximum %{size} MB" msgid "CSV files only, maximum %{size} MB"
msgstr "" msgstr ""
@ -2288,7 +2289,7 @@ msgstr ""
msgid "Export functionality will be available in a future release." msgid "Export functionality will be available in a future release."
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv/membership/import/import_runner.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Failed to read uploaded file: unexpected format" msgid "Failed to read uploaded file: unexpected format"
msgstr "" msgstr ""
@ -2309,20 +2310,35 @@ msgstr ""
msgid "You do not have permission to access this page." msgid "You do not have permission to access this page."
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Manage Member Data" msgid "Manage Member Data"
msgstr "" msgstr ""
#: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
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." 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 "" msgstr ""
#: lib/mv/membership/member/validations/email_change_permission.ex #: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Export members to CSV"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Export to CSV"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "all"
msgstr ""
#: lib/mv/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" 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" msgstr ""
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
@ -2362,10 +2378,45 @@ msgid "SSO / OIDC user"
msgstr "" msgstr ""
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format
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." 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 "" msgstr ""
#: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format
msgid "Import aborted"
msgstr ""
#: lib/mv_web/controllers/member_export_controller.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "paid"
msgstr ""
#: lib/mv_web/controllers/member_export_controller.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "suspended"
msgstr ""
#: lib/mv_web/controllers/member_export_controller.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "unpaid"
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/global_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Failed to prepare CSV import: %{error}"
#~ 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 #~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Only administrators can regenerate cycles" #~ msgid "Only administrators can regenerate cycles"

View file

@ -1,8 +1,9 @@
defmodule Mv.Membership.GroupTest do defmodule Mv.Membership.GroupTest do
@moduledoc """ @moduledoc """
Tests for Group resource validations, CRUD operations, and relationships. Tests for Group resource validations, CRUD operations, and relationships.
Uses async: true; no shared DB state or sandbox constraints.
""" """
use Mv.DataCase, async: false use Mv.DataCase, async: true
alias Mv.Membership alias Mv.Membership

View file

@ -1,8 +1,9 @@
defmodule Mv.Membership.MemberGroupTest do defmodule Mv.Membership.MemberGroupTest do
@moduledoc """ @moduledoc """
Tests for MemberGroup join table resource - validations and cascade delete behavior. Tests for MemberGroup join table resource - validations and cascade delete behavior.
Uses async: true; no shared DB state or sandbox constraints.
""" """
use Mv.DataCase, async: false use Mv.DataCase, async: true
alias Mv.Membership alias Mv.Membership

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,277 @@
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 "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") {:ok, view, _html} = live(conn, "/members")
# simulate search input and check that other members are not listed # simulate search input and check that other members are not listed
html = _html =
view view
|> element("form[role=search]") |> element("form[role=search]")
|> render_submit(%{"query" => "Friedrich"}) |> render_submit(%{"query" => "Friedrich"})
refute html =~ "Greta" refute has_element?(view, "input[data-testid='search-input'][value='Greta']")
html = _html =
view view
|> element("form[role=search]") |> element("form[role=search]")
|> render_submit(%{"query" => "Greta"}) |> render_submit(%{"query" => "Greta"})
refute html =~ "Friedrich" refute has_element?(view, "input[data-testid='search-input'][value='Friedrich']")
end end
end end
end end

View file

@ -0,0 +1,504 @@
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
# Export uses humanize_field (e.g. "first_name" -> "First name"); normalize \r\n line endings
defp export_lines(body) do
body |> String.split(~r/\r?\n/, trim: true)
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 "exports selected members with specified fields", %{
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 = export_lines(body)
header = hd(lines)
# Header + 2 data rows (controller uses humanize_field: "first_name" -> "First name")
assert length(lines) == 3
assert header =~ "First Name,Last Name,Email"
assert body =~ "Alice"
assert body =~ "Bob"
refute body =~ "Carol"
end
test "exports all members when selected_ids is empty", %{
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 = export_lines(body)
# Header + at least 3 data rows (controller uses humanize_field)
assert length(lines) >= 4
assert body =~ "Alice"
assert body =~ "Bob"
assert body =~ "Carol"
end
test "filters out unknown member fields from export", %{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 |> export_lines() |> hd()
assert header =~ "First Name,Email"
refute header =~ "unknown_field"
end
test "export includes membership_fee_status computed field when requested", %{
conn: conn,
member1: m1
} do
payload = %{
"selected_ids" => [m1.id],
"member_fields" => ["first_name"],
"computed_fields" => ["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 |> export_lines() |> hd()
assert header =~ "First Name,Membership Fee Status"
assert body =~ "Alice"
end
test "exports membership fee status computed field with show_current_cycle option", %{
conn: conn,
member1: _m1,
member2: _m2,
member3: _m3
} do
payload = %{
"selected_ids" => [],
"member_fields" => [],
"computed_fields" => ["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 = export_lines(body)
header = hd(lines)
assert header =~ "Membership Fee Status"
end
setup %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create custom fields for different types
{:ok, string_field} =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Phone Number",
value_type: :string
})
|> Ash.create(actor: system_actor)
{:ok, integer_field} =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Membership Number",
value_type: :integer
})
|> Ash.create(actor: system_actor)
{:ok, boolean_field} =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Active Member",
value_type: :boolean
})
|> Ash.create(actor: system_actor)
# Create members with custom field values
{:ok, member_with_string} =
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "String",
email: "test.string@example.com"
},
actor: system_actor
)
{:ok, _cfv_string} =
Mv.Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_with_string.id,
custom_field_id: string_field.id,
value: "+49 123 456789"
})
|> Ash.create(actor: system_actor)
{:ok, member_with_integer} =
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "Integer",
email: "test.integer@example.com"
},
actor: system_actor
)
{:ok, _cfv_integer} =
Mv.Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_with_integer.id,
custom_field_id: integer_field.id,
value: 12_345
})
|> Ash.create(actor: system_actor)
{:ok, member_with_boolean} =
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "Boolean",
email: "test.boolean@example.com"
},
actor: system_actor
)
{:ok, _cfv_boolean} =
Mv.Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_with_boolean.id,
custom_field_id: boolean_field.id,
value: true
})
|> Ash.create(actor: system_actor)
{:ok, member_without_value} =
Mv.Membership.create_member(
%{
first_name: "Test",
last_name: "NoValue",
email: "test.novalue@example.com"
},
actor: system_actor
)
%{
conn: conn,
string_field: string_field,
integer_field: integer_field,
boolean_field: boolean_field,
member_with_string: member_with_string,
member_with_integer: member_with_integer,
member_with_boolean: member_with_boolean,
member_without_value: member_without_value
}
end
test "export includes custom field column with string value", %{
conn: conn,
string_field: string_field,
member_with_string: member
} do
payload = %{
"selected_ids" => [member.id],
"member_fields" => ["first_name", "last_name"],
"custom_field_ids" => [string_field.id],
"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 = export_lines(body)
header = hd(lines)
assert header =~ "First Name"
assert header =~ "Last Name"
assert header =~ "Phone Number"
assert body =~ "Test"
assert body =~ "String"
assert body =~ "+49 123 456789"
end
test "export includes custom field column with integer value", %{
conn: conn,
integer_field: integer_field,
member_with_integer: member
} do
payload = %{
"selected_ids" => [member.id],
"member_fields" => ["first_name"],
"custom_field_ids" => [integer_field.id],
"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 |> export_lines() |> hd()
assert header =~ "First Name"
assert header =~ "Membership Number"
assert body =~ "Test"
assert body =~ "12345"
end
test "export includes custom field column with boolean value", %{
conn: conn,
boolean_field: boolean_field,
member_with_boolean: member
} do
payload = %{
"selected_ids" => [member.id],
"member_fields" => ["first_name"],
"custom_field_ids" => [boolean_field.id],
"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 |> export_lines() |> hd()
assert header =~ "First Name"
assert header =~ "Active Member"
assert body =~ "Test"
# Boolean values are formatted as "Yes" or "No" by CustomFieldValueFormatter
assert body =~ "Yes"
end
test "export shows empty cell for member without custom field value", %{
conn: conn,
string_field: string_field,
member_without_value: member
} do
payload = %{
"selected_ids" => [member.id],
"member_fields" => ["first_name", "last_name"],
"custom_field_ids" => [string_field.id],
"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 = export_lines(body)
header = hd(lines)
data_line = Enum.at(lines, 1)
assert header =~ "Phone Number"
# Empty custom field value should result in empty cell (two consecutive commas)
assert data_line =~ "Test,NoValue,"
end
test "export includes multiple custom fields in correct order", %{
conn: conn,
string_field: string_field,
integer_field: integer_field,
boolean_field: boolean_field,
member_with_string: member
} do
payload = %{
"selected_ids" => [member.id],
"member_fields" => ["first_name"],
"custom_field_ids" => [string_field.id, integer_field.id, boolean_field.id],
"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 |> export_lines() |> hd()
assert header =~ "First Name"
assert header =~ "Phone Number"
assert header =~ "Membership Number"
assert header =~ "Active Member"
# Verify order: member fields first, then custom fields in the order specified
header_parts = String.split(header, ",")
first_name_idx = Enum.find_index(header_parts, &String.contains?(&1, "First Name"))
phone_idx = Enum.find_index(header_parts, &String.contains?(&1, "Phone Number"))
membership_idx = Enum.find_index(header_parts, &String.contains?(&1, "Membership Number"))
active_idx = Enum.find_index(header_parts, &String.contains?(&1, "Active Member"))
assert first_name_idx < phone_idx
assert phone_idx < membership_idx
assert membership_idx < active_idx
end
end
end

View file

@ -1,9 +1,16 @@
defmodule MvWeb.ImportExportLiveTest do defmodule MvWeb.ImportExportLiveTest do
@moduledoc """
Tests for Import/Export LiveView: authorization (business rule), CSV import integration,
and minimal UI smoke tests. CSV parsing/validation logic is covered by
Mv.Membership.Import.MemberCSVTest; here we verify access control and end-to-end outcomes.
"""
use MvWeb.ConnCase, async: true use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
# Helper function to upload CSV file in tests alias Mv.Membership
# Reduces code duplication across multiple test cases
defp put_locale_en(conn), do: Plug.Conn.put_session(conn, "locale", "en")
defp upload_csv_file(view, csv_content, filename \\ "test_import.csv") do defp upload_csv_file(view, csv_content, filename \\ "test_import.csv") do
view view
|> file_input("#csv-upload-form", :csv_file, [ |> file_input("#csv-upload-form", :csv_file, [
@ -18,608 +25,135 @@ defmodule MvWeb.ImportExportLiveTest do
|> render_upload(filename) |> render_upload(filename)
end end
describe "Import/Export LiveView" do defp submit_import(view), do: view |> form("#csv-upload-form", %{}) |> render_submit()
setup %{conn: conn} do
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
{:ok, conn: conn, admin_user: admin_user}
end
test "renders the import/export page", %{conn: conn} do defp wait_for_import_completion, do: Process.sleep(1000)
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
assert html =~ "Import/Export" # ---------- Business logic: Authorization ----------
end describe "Authorization" do
test "non-admin user cannot access import/export page and sees permission error", %{
test "displays import section for admin user", %{conn: conn} do conn: conn
{:ok, _view, html} = live(conn, ~p"/admin/import-export") } do
assert html =~ "Import Members (CSV)"
end
test "displays export section placeholder", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
assert html =~ "Export Members (CSV)" or html =~ "Export"
end
end
describe "CSV Import Section" do
setup %{conn: conn} do
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
{:ok, conn: conn, admin_user: admin_user}
end
test "admin user sees import section", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
# Check for import section heading or identifier
assert html =~ "Import" or html =~ "CSV" or html =~ "member_import"
end
test "admin user sees custom fields notice", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
# Check for custom fields notice text
assert html =~ "Use the data field name"
end
test "admin user sees template download links", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
html = render(view)
# Check for English template link
assert html =~ "member_import_en.csv" or html =~ "/templates/member_import_en.csv"
# Check for German template link
assert html =~ "member_import_de.csv" or html =~ "/templates/member_import_de.csv"
end
test "template links use static path helper", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
html = render(view)
# Check that links contain the static path pattern
# Static paths typically start with /templates/ or contain the full path
assert html =~ "/templates/member_import_en.csv" or
html =~ ~r/href=["'][^"']*member_import_en\.csv["']/
assert html =~ "/templates/member_import_de.csv" or
html =~ ~r/href=["'][^"']*member_import_de\.csv["']/
end
test "admin user sees file upload input", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
html = render(view)
# Check for file input element
assert html =~ ~r/type=["']file["']/i or html =~ "phx-hook" or html =~ "upload"
end
test "file upload has CSV-only restriction", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
html = render(view)
# Check for CSV file type restriction in help text or accept attribute
assert html =~ ~r/\.csv/i or html =~ "CSV" or html =~ ~r/accept=["'][^"']*csv["']/i
end
test "non-admin user sees permission error", %{conn: conn} do
# Member (own_data) user
member_user = Mv.Fixtures.user_with_role_fixture("own_data") member_user = Mv.Fixtures.user_with_role_fixture("own_data")
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
# Router plug redirects non-admin users before LiveView loads conn =
assert {:error, {:redirect, %{to: redirect_path, flash: %{"error" => error_message}}}} = conn
|> MvWeb.ConnCase.conn_with_password_user(member_user)
|> put_locale_en()
assert {:error, {:redirect, %{to: redirect_path, flash: %{"error" => msg}}}} =
live(conn, ~p"/admin/import-export") live(conn, ~p"/admin/import-export")
# Should redirect to user profile page
assert redirect_path =~ "/users/" assert redirect_path =~ "/users/"
# Should show permission error in flash assert msg =~ "don't have permission"
assert error_message =~ "don't have permission"
end
end end
describe "CSV Import - Import" do test "admin user can access page and run import", %{conn: conn} do
setup %{conn: conn} do conn = put_locale_en(conn)
# Ensure admin user
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
# Read valid CSV fixture
csv_content = csv_content =
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"]) Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|> File.read!() |> File.read!()
{:ok, conn: conn, admin_user: admin_user, csv_content: csv_content}
end
test "admin can upload CSV and start import", %{conn: conn, csv_content: csv_content} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export") {:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Simulate file upload using helper function
upload_csv_file(view, csv_content) upload_csv_file(view, csv_content)
submit_import(view)
wait_for_import_completion()
# Trigger start_import event via form submit assert has_element?(view, "[data-testid='import-results-panel']")
assert view assert has_element?(view, "[data-testid='import-summary']")
|> form("#csv-upload-form", %{})
|> render_submit()
# Check that import has started using data-testid
# Either import-progress-container exists (import started) OR we see a CSV error
html = render(view) html = render(view)
import_started = has_element?(view, "[data-testid='import-progress-container']") refute html =~ "Import aborted"
no_admin_error = not (html =~ "Only administrators can import") assert html =~ "Successfully inserted"
# If import failed, it should be a CSV parsing error, not an admin error # Business outcome: two members from fixture were created
if html =~ "Failed to prepare CSV import" do system_actor = Mv.Helpers.SystemActor.get_system_actor()
# This is acceptable - CSV might have issues, but admin check passed {:ok, members} = Membership.list_members(actor: system_actor)
assert no_admin_error
else imported =
# Import should have started - check for progress container Enum.filter(members, fn m ->
assert import_started m.email in ["alice.smith@example.com", "bob.johnson@example.com"]
end)
assert length(imported) == 2
end end
end end
test "admin import initializes progress correctly", %{conn: conn, csv_content: csv_content} do # ---------- Business logic: Import behaviour (integration) ----------
{:ok, view, _html} = live(conn, ~p"/admin/import-export") describe "CSV Import - integration" do
# Simulate file upload using helper function
upload_csv_file(view, csv_content)
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Check that import has started using data-testid
html = render(view)
import_started = has_element?(view, "[data-testid='import-progress-container']")
no_admin_error = not (html =~ "Only administrators can import")
# If import failed, it should be a CSV parsing error, not an admin error
if html =~ "Failed to prepare CSV import" do
# This is acceptable - CSV might have issues, but admin check passed
assert no_admin_error
else
# Import should have started - check for progress container
assert import_started
end
end
test "non-admin cannot start import", %{conn: conn} do
# Member (own_data) user
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
# Router plug redirects non-admin users before LiveView loads
assert {:error, {:redirect, %{to: redirect_path, flash: %{"error" => error_message}}}} =
live(conn, ~p"/admin/import-export")
# Should redirect to user profile page
assert redirect_path =~ "/users/"
# Should show permission error in flash
assert error_message =~ "don't have permission"
end
test "invalid CSV shows user-friendly error", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Create invalid CSV (missing required fields)
invalid_csv = "invalid_header\nincomplete_row"
# Simulate file upload using helper function
upload_csv_file(view, invalid_csv, "invalid.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Check for error message (flash)
html = render(view)
assert html =~ "error" or html =~ "failed" or html =~ "Failed to prepare"
end
@tag :skip
test "empty CSV shows error", %{conn: conn} do
# Skip this test - Phoenix LiveView has issues with empty file uploads in tests
# The error is handled correctly in production, but test framework has limitations
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
empty_csv = " "
csv_path = Path.join([System.tmp_dir!(), "empty_#{System.unique_integer()}.csv"])
File.write!(csv_path, empty_csv)
view
|> file_input("#csv-upload-form", :csv_file, [
%{
last_modified: System.system_time(:second),
name: "empty.csv",
content: empty_csv,
size: byte_size(empty_csv),
type: "text/csv"
}
])
|> render_upload("empty.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Check for error message
html = render(view)
assert html =~ "error" or html =~ "empty" or html =~ "failed" or html =~ "Failed to prepare"
end
end
describe "CSV Import - Step 3: Chunk Processing" do
setup %{conn: conn} do setup %{conn: conn} do
# Ensure admin user
admin_user = Mv.Fixtures.user_with_role_fixture("admin") admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
# Read valid CSV fixture conn =
valid_csv_content = conn
|> MvWeb.ConnCase.conn_with_password_user(admin_user)
|> put_locale_en()
valid_csv =
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"]) Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|> File.read!() |> File.read!()
# Read invalid CSV fixture invalid_csv =
invalid_csv_content =
Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"]) Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"])
|> File.read!() |> File.read!()
{:ok, unknown_cf_csv =
conn: conn,
admin_user: admin_user,
valid_csv_content: valid_csv_content,
invalid_csv_content: invalid_csv_content}
end
test "happy path: valid CSV processes all chunks and shows done status", %{
conn: conn,
valid_csv_content: csv_content
} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Simulate file upload using helper function
upload_csv_file(view, csv_content)
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for processing to complete
# In test mode, chunks are processed synchronously and messages are sent via send/2
# render(view) processes handle_info messages, so we call it multiple times
# to ensure all messages are processed
Process.sleep(1000)
# Check that import-results-panel exists (import completed)
assert has_element?(view, "[data-testid='import-results-panel']")
# Verify success count is shown
html = render(view)
assert html =~ "Successfully inserted" or html =~ "inserted"
end
test "error handling: invalid CSV shows errors with line numbers", %{
conn: conn,
invalid_csv_content: csv_content
} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Simulate file upload using helper function
upload_csv_file(view, csv_content, "invalid_import.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for chunk processing
Process.sleep(1000)
# Check that import-results-panel exists (import completed with errors)
assert has_element?(view, "[data-testid='import-results-panel']")
# Check that error list exists
assert has_element?(view, "[data-testid='import-error-list']")
html = render(view)
# Should show failure count > 0
assert html =~ "failed" or html =~ "error" or html =~ "Failed"
# Should show line numbers in errors (from service, not recalculated)
# Line numbers should be 2, 3 (header is line 1)
assert html =~ "2" or html =~ "3" or html =~ "line"
end
test "error cap: many failing rows caps errors at 50", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Generate CSV with 100 invalid rows (all missing email)
header = "first_name;last_name;email;street;postal_code;city\n"
invalid_rows = for i <- 1..100, do: "Row#{i};Last#{i};;Street#{i};12345;City#{i}\n"
large_invalid_csv = header <> Enum.join(invalid_rows)
# Simulate file upload using helper function
upload_csv_file(view, large_invalid_csv, "large_invalid.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for chunk processing
Process.sleep(1000)
# Check that import-results-panel exists (import completed)
assert has_element?(view, "[data-testid='import-results-panel']")
html = render(view)
# Should show failed count == 100
assert html =~ "100" or html =~ "failed"
# Errors should be capped at 50 (but we can't easily check exact count in HTML)
# The important thing is that processing completes without crashing
# Import is done when import-results-panel exists
end
test "chunk scheduling: progress updates show chunk processing", %{
conn: conn,
valid_csv_content: csv_content
} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Simulate file upload using helper function
upload_csv_file(view, csv_content)
view
|> form("#csv-upload-form", %{})
|> render_submit()
# In test mode chunks run synchronously, so we may already be :done when we check.
# Accept either progress container (if we caught :running) or results panel (if already :done).
_html = render(view)
assert has_element?(view, "[data-testid='import-progress-container']") or
has_element?(view, "[data-testid='import-results-panel']")
# Wait for final state and assert results panel is shown
Process.sleep(500)
assert has_element?(view, "[data-testid='import-results-panel']")
end
end
describe "CSV Import - Step 4: Results UI" do
setup %{conn: conn} do
# Ensure admin user
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
# Read valid CSV fixture
valid_csv_content =
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|> File.read!()
# Read invalid CSV fixture
invalid_csv_content =
Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"])
|> File.read!()
# Read CSV with unknown custom field
unknown_custom_field_csv =
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_unknown_custom_field.csv"]) Path.join([__DIR__, "..", "..", "fixtures", "csv_with_unknown_custom_field.csv"])
|> File.read!() |> File.read!()
{:ok, {:ok,
conn: conn, conn: conn,
admin_user: admin_user, valid_csv: valid_csv,
valid_csv_content: valid_csv_content, invalid_csv: invalid_csv,
invalid_csv_content: invalid_csv_content, unknown_custom_field_csv: unknown_cf_csv}
unknown_custom_field_csv: unknown_custom_field_csv}
end end
test "success rendering: valid CSV shows success count", %{ test "invalid CSV shows user-friendly prepare error", %{conn: conn} do
conn: conn,
valid_csv_content: csv_content
} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export") {:ok, view, _html} = live(conn, ~p"/admin/import-export")
upload_csv_file(view, "invalid_header\nincomplete_row", "invalid.csv")
# Simulate file upload using helper function submit_import(view)
upload_csv_file(view, csv_content)
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for processing to complete
Process.sleep(1000)
# Check that import-results-panel exists (import completed)
assert has_element?(view, "[data-testid='import-results-panel']")
# Verify success count is shown
html = render(view) html = render(view)
assert html =~ "Successfully inserted" or html =~ "inserted" assert html =~ "Failed to prepare CSV import"
end end
test "error rendering: invalid CSV shows failure count and error list with line numbers", %{ test "invalid rows show errors with correct CSV line numbers", %{
conn: conn, conn: conn,
invalid_csv_content: csv_content invalid_csv: csv_content
} do } do
{:ok, view, _html} = live(conn, ~p"/admin/import-export") {:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Simulate file upload using helper function
upload_csv_file(view, csv_content, "invalid_import.csv") upload_csv_file(view, csv_content, "invalid_import.csv")
submit_import(view)
wait_for_import_completion()
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for processing
Process.sleep(1000)
# Check that import-results-panel exists (import completed with errors)
assert has_element?(view, "[data-testid='import-results-panel']") assert has_element?(view, "[data-testid='import-results-panel']")
# Check that error list exists
assert has_element?(view, "[data-testid='import-error-list']") assert has_element?(view, "[data-testid='import-error-list']")
html = render(view) html = render(view)
# Should show failure count assert html =~ "Failed"
assert html =~ "Failed" or html =~ "failed" # Fixture has invalid email on line 2 and missing email on line 3
assert html =~ "Line 2"
# Should show error list with line numbers (from service, not recalculated) assert html =~ "Line 3"
assert html =~ "Line" or html =~ "line" or html =~ "2" or html =~ "3"
end end
test "warning rendering: CSV with unknown custom field shows warnings block", %{ test "error list is capped and truncation message is shown", %{conn: conn} do
conn: conn,
unknown_custom_field_csv: csv_content
} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export") {:ok, view, _html} = live(conn, ~p"/admin/import-export")
header = "first_name;last_name;email;street;postal_code;city\n"
csv_path = invalid_rows =
Path.join([System.tmp_dir!(), "unknown_custom_#{System.unique_integer()}.csv"]) for i <- 1..100, do: "Row#{i};Last#{i};;Street#{i};12345;City#{i}\n"
File.write!(csv_path, csv_content) upload_csv_file(view, header <> Enum.join(invalid_rows), "large_invalid.csv")
submit_import(view)
wait_for_import_completion()
view
|> file_input("#csv-upload-form", :csv_file, [
%{
last_modified: System.system_time(:second),
name: "unknown_custom.csv",
content: csv_content,
size: byte_size(csv_content),
type: "text/csv"
}
])
|> render_upload("unknown_custom.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for processing
Process.sleep(1000)
# Check that import-results-panel exists (import completed)
assert has_element?(view, "[data-testid='import-results-panel']") assert has_element?(view, "[data-testid='import-results-panel']")
assert has_element?(view, "[data-testid='import-error-list']")
html = render(view) html = render(view)
# Should show warnings block (if warnings were generated) assert html =~ "100"
# Warnings are generated when unknown custom field columns are detected assert html =~ "Error list truncated"
has_warnings = html =~ "Warning" or html =~ "warning" or html =~ "Warnings"
# If warnings exist, they should contain the column name
if has_warnings do
assert html =~ "UnknownCustomField" or html =~ "unknown" or html =~ "Unknown column" or
html =~ "will be ignored"
end end
# Import should complete (either with or without warnings) test "row limit is enforced (1001 rows rejected)", %{conn: conn} do
# Verified by import-results-panel existence above
end
test "A11y: file input has label", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
# Check for label associated with file input
assert html =~ ~r/<label[^>]*for=["']csv_file["']/i or
html =~ ~r/<label[^>]*>.*CSV File/i
end
test "A11y: status/progress container has aria-live", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export") {:ok, view, _html} = live(conn, ~p"/admin/import-export")
html = render(view)
# Check for aria-live attribute in status area
assert html =~ ~r/aria-live=["']polite["']/i
end
test "A11y: links have descriptive text", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
# Check that links have descriptive text (not just "click here")
# Template links should have text like "English Template" or "German Template"
assert html =~ "English Template" or html =~ "German Template" or
html =~ "English" or html =~ "German"
# Import page has link "Manage Member Data" and info text about "data field"
assert html =~ "Manage Member Data" or html =~ "data field" or html =~ "Data field"
end
end
describe "CSV Import - Step 5: Edge Cases" do
setup %{conn: conn} do
# Ensure admin user
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
{:ok, conn: conn, admin_user: admin_user}
end
test "BOM + semicolon delimiter: import succeeds", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Read CSV with BOM
csv_content =
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_bom_semicolon.csv"])
|> File.read!()
# Simulate file upload using helper function
upload_csv_file(view, csv_content, "bom_import.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for processing
Process.sleep(1000)
# Check that import-results-panel exists (import completed successfully)
assert has_element?(view, "[data-testid='import-results-panel']")
html = render(view)
# Should succeed (BOM is stripped automatically)
assert html =~ "Successfully inserted" or html =~ "inserted"
# Should not show error about BOM
refute html =~ "BOM" or html =~ "encoding"
end
test "empty lines: line numbers in errors correspond to physical CSV lines", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
# CSV with empty line: header (line 1), valid row (line 2), empty (line 3), invalid (line 4)
csv_content =
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_empty_lines.csv"])
|> File.read!()
# Simulate file upload using helper function
upload_csv_file(view, csv_content, "empty_lines.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
# Wait for processing
Process.sleep(1000)
html = render(view)
# Should show error with correct line number (line 4, not line 3)
# The error should be on the line with invalid email, which is after the empty line
assert html =~ "Line 4" or html =~ "line 4" or html =~ "4"
# Should show error message
assert html =~ "error" or html =~ "Error" or html =~ "invalid"
end
test "too many rows (1001): import is rejected with user-friendly error", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Generate CSV with 1001 rows dynamically
header = "first_name;last_name;email;street;postal_code;city\n" header = "first_name;last_name;email;street;postal_code;city\n"
rows = rows =
@ -627,43 +161,122 @@ defmodule MvWeb.ImportExportLiveTest do
"Row#{i};Last#{i};email#{i}@example.com;Street#{i};12345;City#{i}\n" "Row#{i};Last#{i};email#{i}@example.com;Street#{i};12345;City#{i}\n"
end end
large_csv = header <> Enum.join(rows) upload_csv_file(view, header <> Enum.join(rows), "too_many_rows.csv")
submit_import(view)
# Simulate file upload using helper function
upload_csv_file(view, large_csv, "too_many_rows.csv")
view
|> form("#csv-upload-form", %{})
|> render_submit()
html = render(view) html = render(view)
# Should show user-friendly error about row limit assert html =~ "exceeds"
assert html =~ "exceeds" or html =~ "maximum" or html =~ "limit" or html =~ "1000" or
html =~ "Failed to prepare"
end end
test "wrong file type (.txt): upload shows error", %{conn: conn} do test "BOM and semicolon delimiter are accepted", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export") {:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Create .txt file (not .csv) csv_content =
txt_content = "This is not a CSV file\nJust some text\n" Path.join([__DIR__, "..", "..", "fixtures", "csv_with_bom_semicolon.csv"])
txt_path = Path.join([System.tmp_dir!(), "wrong_type_#{System.unique_integer()}.txt"]) |> File.read!()
File.write!(txt_path, txt_content)
# Try to upload .txt file upload_csv_file(view, csv_content, "bom_import.csv")
# Note: allow_upload is configured to accept only .csv, so this should fail submit_import(view)
# In tests, we can't easily simulate file type rejection, but we can check wait_for_import_completion()
# that the UI shows appropriate help text
assert has_element?(view, "[data-testid='import-results-panel']")
html = render(view) html = render(view)
# Should show CSV-only restriction in help text assert html =~ "Successfully inserted"
assert html =~ "CSV" or html =~ "csv" or html =~ ".csv" refute html =~ "BOM"
end end
test "file input has correct accept attribute for CSV only", %{conn: conn} do test "physical line numbers in errors (empty line does not shift numbering)", %{
{:ok, _view, html} = live(conn, ~p"/admin/import-export") conn: conn
} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
# Check that file input has accept attribute for CSV csv_content =
assert html =~ ~r/accept=["'][^"']*csv["']/i or html =~ "CSV files only" Path.join([__DIR__, "..", "..", "fixtures", "csv_with_empty_lines.csv"])
|> File.read!()
upload_csv_file(view, csv_content, "empty_lines.csv")
submit_import(view)
wait_for_import_completion()
assert has_element?(view, "[data-testid='import-error-list']")
html = render(view)
# Invalid row is on physical line 4 (header, valid row, empty line, then invalid)
assert html =~ "Line 4"
end
test "unknown custom field column produces warnings", %{
conn: conn,
unknown_custom_field_csv: csv_content
} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
upload_csv_file(view, csv_content, "unknown_custom.csv")
submit_import(view)
wait_for_import_completion()
assert has_element?(view, "[data-testid='import-results-panel']")
assert has_element?(view, "[data-testid='import-warnings']")
html = render(view)
assert html =~ "Warnings"
end end
end end
# ---------- UI (smoke / framework): tagged for exclusion from fast CI ----------
describe "Import/Export page UI" do
@describetag :ui
setup %{conn: conn} do
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn =
conn
|> MvWeb.ConnCase.conn_with_password_user(admin_user)
|> put_locale_en()
{:ok, conn: conn}
end
test "page loads and shows import form and export placeholder", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
assert has_element?(view, "[data-testid='csv-upload-form']")
assert has_element?(view, "[data-testid='start-import-button']")
assert has_element?(view, "[data-testid='custom-fields-link']")
html = render(view)
assert html =~ "Import Members (CSV)"
assert html =~ "Export Members (CSV)"
assert html =~ "Export functionality will be available"
end
test "template links and file input are present", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
assert has_element?(view, "a[href*='/templates/member_import_en.csv']")
assert has_element?(view, "a[href*='/templates/member_import_de.csv']")
assert has_element?(view, "label[for='csv_file']")
assert has_element?(view, "#csv_file_help")
assert has_element?(view, "[data-testid='csv-upload-form'] input[type='file']")
end
test "after successful import, progress container has aria-live", %{conn: conn} do
csv_content =
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|> File.read!()
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
upload_csv_file(view, csv_content)
submit_import(view)
wait_for_import_completion()
assert has_element?(view, "[data-testid='import-progress-container']")
html = render(view)
assert html =~ "aria-live"
end
end
# Skip: LiveView test harness does not reliably support empty/minimal file uploads.
# See docs/csv-member-import-v1.md (Issue #9).
@tag :skip
test "empty CSV shows error", %{conn: conn} do
conn = put_locale_en(conn)
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
upload_csv_file(view, " ", "empty.csv")
submit_import(view)
html = render(view)
assert html =~ "Failed to prepare"
end
end end

View file

@ -9,6 +9,7 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
require Ash.Query require Ash.Query
describe "error handling - flash messages" do describe "error handling - flash messages" do
@describetag :ui
test "shows flash message when member creation fails with validation error", %{conn: conn} do test "shows flash message when member creation fails with validation error", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()

View file

@ -225,7 +225,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|> element("[data-testid='custom_field_#{field.id}']") |> element("[data-testid='custom_field_#{field.id}']")
|> render_click() |> render_click()
assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") # Patch URL may include fields param (current field selection); assert sort outcome instead
assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='ascending']")
end end
test "NULL values and empty strings are always sorted last (ASC)", %{conn: conn} do test "NULL values and empty strings are always sorted last (ASC)", %{conn: conn} do

View file

@ -46,34 +46,30 @@ defmodule MvWeb.MemberLive.IndexTest do
|> Ash.create!(actor: actor) |> Ash.create!(actor: actor)
end end
test "shows translated title in German", %{conn: conn} do describe "translations" do
conn = conn_with_oidc_user(conn) @describetag :ui
conn = Plug.Test.init_test_session(conn, locale: "de")
{:ok, _view, html} = live(conn, "/members")
# Expected German title
assert html =~ "Mitglieder"
end
test "shows translated title in English", %{conn: conn} do test "shows translated title and button text by locale", %{conn: conn} do
conn = conn_with_oidc_user(conn) 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") Gettext.put_locale(MvWeb.Gettext, "en")
{:ok, _view, html} = live(conn, "/members") c
# Expected English title end}
assert html =~ "Members" ]
end
test "shows translated button text in German", %{conn: conn} do for {_locale, expected_title, expected_button, set_locale} <- locales do
conn = conn_with_oidc_user(conn) base = conn_with_oidc_user(conn) |> set_locale.()
conn = Plug.Test.init_test_session(conn, locale: "de")
{:ok, _view, html} = live(conn, "/members/new")
assert html =~ "Speichern"
end
test "shows translated button text in English", %{conn: conn} do {:ok, _view, index_html} = live(base, "/members")
conn = conn_with_oidc_user(conn) assert index_html =~ expected_title
Gettext.put_locale(MvWeb.Gettext, "en")
{:ok, _view, html} = live(conn, "/members/new") base_form = conn_with_oidc_user(conn) |> set_locale.()
assert html =~ "Save" {:ok, _view, form_html} = live(base_form, "/members/new")
assert form_html =~ expected_button
end
end end
test "shows translated flash message after creating a member in German", %{conn: conn} do 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") assert has_element?(index_view, "#flash-group", "Member created successfully")
end end
end
describe "sorting integration" do describe "sorting integration" do
@describetag :ui
test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
@ -200,6 +198,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end end
describe "URL param handling" do describe "URL param handling" do
@describetag :ui
test "handle_params reads sort query and applies it", %{conn: conn} do test "handle_params reads sort query and applies it", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc") {:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
@ -226,6 +225,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end end
describe "search and sort integration" do describe "search and sort integration" do
@describetag :ui
test "search maintains sort state", %{conn: conn} do test "search maintains sort state", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc") {:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
@ -253,6 +253,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end end
end end
@tag :ui
test "handle_info(:search_changed) updates assigns with search results", %{conn: conn} do test "handle_info(:search_changed) updates assigns with search results", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
@ -521,6 +522,50 @@ defmodule MvWeb.MemberLive.IndexTest do
end end
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 describe "cycle status filter" do
# Helper to create a member (only used in this describe block) # Helper to create a member (only used in this describe block)
defp create_member(attrs, actor) do defp create_member(attrs, actor) do
@ -780,6 +825,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|> Ash.create!(actor: system_actor) |> Ash.create!(actor: system_actor)
end end
@tag :ui
test "mount initializes boolean_custom_field_filters as empty map", %{conn: conn} do test "mount initializes boolean_custom_field_filters as empty map", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
@ -788,6 +834,7 @@ defmodule MvWeb.MemberLive.IndexTest do
assert state.socket.assigns.boolean_custom_field_filters == %{} assert state.socket.assigns.boolean_custom_field_filters == %{}
end end
@tag :ui
test "mount initializes boolean_custom_fields as empty list when no boolean fields exist", %{ test "mount initializes boolean_custom_fields as empty list when no boolean fields exist", %{
conn: conn conn: conn
} do } do
@ -1762,6 +1809,7 @@ defmodule MvWeb.MemberLive.IndexTest do
refute html_false =~ "NoValue" refute html_false =~ "NoValue"
end end
@tag :ui
test "boolean custom field appears in filter dropdown after being added", %{conn: conn} do test "boolean custom field appears in filter dropdown after being added", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)