diff --git a/lib/mv/authorization/permission_sets.ex b/lib/mv/authorization/permission_sets.ex index b0e7015..ea878a2 100644 --- a/lib/mv/authorization/permission_sets.ex +++ b/lib/mv/authorization/permission_sets.ex @@ -166,8 +166,9 @@ defmodule Mv.Authorization.PermissionSets do "/users/:id", "/users/:id/edit", "/users/:id/show/edit", - # Member list + # Member list and CSV export "/members", + "/members/export.csv", # Member detail "/members/:id", # Custom field values overview @@ -223,6 +224,7 @@ defmodule Mv.Authorization.PermissionSets do "/users/:id/edit", "/users/:id/show/edit", "/members", + "/members/export.csv", # Create member "/members/new", "/members/:id", diff --git a/lib/mv/membership/custom_field_value_formatter.ex b/lib/mv/membership/custom_field_value_formatter.ex new file mode 100644 index 0000000..9709353 --- /dev/null +++ b/lib/mv/membership/custom_field_value_formatter.ex @@ -0,0 +1,55 @@ +defmodule Mv.Membership.CustomFieldValueFormatter do + @moduledoc """ + Neutral formatter for custom field values (e.g. CSV export). + + Same logic as the member overview Formatter but without Gettext or web helpers, + so it can be used from the Membership context. For boolean: "Yes"/"No"; + for date: European format (dd.mm.yyyy). + """ + @doc """ + Formats a custom field value for plain text (e.g. CSV). + + Handles nil, Ash.Union, JSONB map, and direct values. Uses custom_field.value_type + for typing. Boolean -> "Yes"/"No", Date -> dd.mm.yyyy. + """ + def format_custom_field_value(nil, _custom_field), do: "" + + def format_custom_field_value(%Ash.Union{value: value, type: type}, custom_field) do + format_value_by_type(value, type, custom_field) + end + + def format_custom_field_value(value, custom_field) when is_map(value) do + type = Map.get(value, "type") || Map.get(value, "_union_type") + val = Map.get(value, "value") || Map.get(value, "_union_value") + format_value_by_type(val, type, custom_field) + end + + def format_custom_field_value(value, custom_field) do + format_value_by_type(value, custom_field.value_type, custom_field) + end + + defp format_value_by_type(value, :string, _), do: to_string(value) + defp format_value_by_type(value, :integer, _), do: to_string(value) + + defp format_value_by_type(value, type, _) when type in [:string, :email] and is_binary(value) do + if String.trim(value) == "", do: "", else: value + end + + defp format_value_by_type(value, :email, _), do: to_string(value) + defp format_value_by_type(value, :boolean, _) when value == true, do: "Yes" + defp format_value_by_type(value, :boolean, _) when value == false, do: "No" + defp format_value_by_type(value, :boolean, _), do: to_string(value) + + defp format_value_by_type(%Date{} = date, :date, _) do + Calendar.strftime(date, "%d.%m.%Y") + end + + defp format_value_by_type(value, :date, _) when is_binary(value) do + case Date.from_iso8601(value) do + {:ok, date} -> Calendar.strftime(date, "%d.%m.%Y") + _ -> value + end + end + + defp format_value_by_type(value, _type, _), do: to_string(value) +end diff --git a/lib/mv/membership/member_export.ex b/lib/mv/membership/member_export.ex new file mode 100644 index 0000000..5f771cc --- /dev/null +++ b/lib/mv/membership/member_export.ex @@ -0,0 +1,344 @@ +defmodule Mv.Membership.MemberExport do + @moduledoc """ + Builds member list and column specs for CSV export. + + Used by `MvWeb.MemberExportController`. Does not perform translations; + the controller applies headers (e.g. via `MemberFields.label` / gettext) + and sends the download. + """ + + require Ash.Query + import Ash.Expr + + alias Mv.Membership.CustomField + alias Mv.Membership.Member + alias Mv.Membership.MemberExportSort + alias MvWeb.MemberLive.Index.MembershipFeeStatus + + @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ + ["membership_fee_status", "payment_status"] + @computed_export_fields ["membership_fee_status", "payment_status"] + @custom_field_prefix Mv.Constants.custom_field_prefix() + @domain_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + + @doc """ + Fetches members and column specs for export. + + - `actor` - Ash actor (e.g. current user) + - `parsed` - Map from controller's parse_and_validate (selected_ids, member_fields, etc.) + + Returns `{:ok, members, column_specs}` or `{:error, :forbidden}`. + Column specs have `:kind`, `:key`, and for custom fields `:custom_field`; + the controller adds `:header` and optional computed columns to members before CSV export. + """ + @spec fetch(struct(), map()) :: + {:ok, [struct()], [map()]} | {:error, :forbidden} + def fetch(actor, parsed) do + custom_field_ids_union = + (parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})) |> Enum.uniq() + + with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(custom_field_ids_union, actor), + {:ok, members} <- load_members(actor, parsed, custom_fields_by_id) do + column_specs = build_column_specs(parsed, custom_fields_by_id) + {:ok, members, column_specs} + end + end + + defp load_custom_fields_by_id([], _actor), do: {:ok, %{}} + + defp load_custom_fields_by_id(custom_field_ids, actor) do + query = + CustomField + |> Ash.Query.filter(expr(id in ^custom_field_ids)) + |> Ash.Query.select([:id, :name, :value_type]) + + case Ash.read(query, actor: actor) do + {:ok, custom_fields} -> + by_id = + Enum.reduce(custom_field_ids, %{}, fn id, acc -> + case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do + nil -> acc + cf -> Map.put(acc, id, cf) + end + end) + + {:ok, by_id} + + {:error, %Ash.Error.Forbidden{}} -> + {:error, :forbidden} + end + end + + defp build_column_specs(parsed, custom_fields_by_id) do + member_specs = + Enum.map(parsed.member_fields, fn f -> + if f in parsed.selectable_member_fields do + %{kind: :member_field, key: f} + else + %{kind: :computed, key: String.to_existing_atom(f)} + end + end) + + custom_specs = + parsed.custom_field_ids + |> Enum.map(fn id -> Map.get(custom_fields_by_id, id) end) + |> Enum.reject(&is_nil/1) + |> Enum.map(fn cf -> %{kind: :custom_field, key: cf.id, custom_field: cf} end) + + member_specs ++ custom_specs + end + + defp load_members(actor, parsed, custom_fields_by_id) do + select_fields = + [:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1) + + custom_field_ids_union = parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{}) + + need_cycles = + parsed.show_current_cycle or parsed.cycle_status_filter != nil or + parsed.computed_fields != [] + + query = + Member + |> Ash.Query.new() + |> Ash.Query.select(select_fields) + |> load_custom_field_values_query(custom_field_ids_union) + |> maybe_load_cycles(need_cycles, parsed.show_current_cycle) + + query = + if parsed.selected_ids != [] do + Ash.Query.filter(query, expr(id in ^parsed.selected_ids)) + else + query + |> apply_search(parsed.query) + |> then(fn q -> + {q, _sort_after_load} = maybe_sort(q, parsed.sort_field, parsed.sort_order) + q + end) + end + + case Ash.read(query, actor: actor) do + {:ok, members} -> + members = + if parsed.selected_ids == [] do + members + |> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle) + |> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters( + parsed.boolean_filters || %{}, + Map.values(custom_fields_by_id) + ) + else + members + end + + members = + if parsed.selected_ids == [] and sort_after_load?(parsed.sort_field) do + sort_members_by_custom_field( + members, + parsed.sort_field, + parsed.sort_order, + Map.values(custom_fields_by_id) + ) + else + members + end + + {:ok, members} + + {:error, %Ash.Error.Forbidden{}} -> + {:error, :forbidden} + end + end + + defp load_custom_field_values_query(query, []), do: query + + defp load_custom_field_values_query(query, custom_field_ids) do + cfv_query = + Mv.Membership.CustomFieldValue + |> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids)) + |> Ash.Query.load(custom_field: [:id, :name, :value_type]) + + Ash.Query.load(query, custom_field_values: cfv_query) + end + + defp apply_search(query, nil), do: query + defp apply_search(query, ""), do: query + + defp apply_search(query, q) when is_binary(q) do + if String.trim(q) != "" do + Member.fuzzy_search(query, %{query: q}) + else + query + end + end + + defp maybe_sort(query, nil, _order), do: {query, false} + defp maybe_sort(query, _field, nil), do: {query, false} + + defp maybe_sort(query, field, order) when is_binary(field) do + if custom_field_sort?(field) do + {query, true} + else + field_atom = String.to_existing_atom(field) + + if field_atom in (Mv.Constants.member_fields() -- [:notes]) do + {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false} + else + {query, false} + end + end + rescue + ArgumentError -> {query, false} + end + + defp sort_after_load?(field) when is_binary(field), + do: String.starts_with?(field, @custom_field_prefix) + + defp sort_after_load?(_), do: false + + defp sort_members_by_custom_field(members, _field, _order, _custom_fields) when members == [], + do: [] + + defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do + id_str = String.trim_leading(field, @custom_field_prefix) + custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) + if is_nil(custom_field), do: members + + key_fn = fn member -> + cfv = find_cfv(member, custom_field) + raw = if cfv, do: cfv.value, else: nil + MemberExportSort.custom_field_sort_key(custom_field.value_type, raw) + end + + members + |> Enum.map(fn m -> {m, key_fn.(m)} end) + |> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end) + |> Enum.map(fn {m, _} -> m end) + end + + defp find_cfv(member, custom_field) do + (member.custom_field_values || []) + |> Enum.find(fn cfv -> + to_string(cfv.custom_field_id) == to_string(custom_field.id) or + (Map.get(cfv, :custom_field) && + to_string(cfv.custom_field.id) == to_string(custom_field.id)) + end) + end + + defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix) + + defp maybe_load_cycles(query, false, _show_current), do: query + + defp maybe_load_cycles(query, true, show_current) do + MembershipFeeStatus.load_cycles_for_members(query, show_current) + end + + defp apply_cycle_status_filter(members, nil, _show_current), do: members + + defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do + MembershipFeeStatus.filter_members_by_cycle_status(members, status, show_current) + end + + defp apply_cycle_status_filter(members, _status, _show_current), do: members + + # Called by controller to build parsed map from raw params (kept here so controller stays thin) + @doc """ + Parses and validates export params (from JSON payload). + + Returns a map with :selected_ids, :member_fields, :selectable_member_fields, + :computed_fields, :custom_field_ids, :query, :sort_field, :sort_order, + :show_current_cycle, :cycle_status_filter, :boolean_filters. + """ + @spec parse_params(map()) :: map() + def parse_params(params) do + member_fields = filter_allowed_member_fields(extract_list(params, "member_fields")) + {selectable_member_fields, computed_fields} = split_member_fields(member_fields) + + %{ + selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")), + member_fields: member_fields, + selectable_member_fields: selectable_member_fields, + computed_fields: computed_fields, + custom_field_ids: filter_valid_uuids(extract_list(params, "custom_field_ids")), + query: extract_string(params, "query"), + sort_field: extract_string(params, "sort_field"), + sort_order: extract_sort_order(params), + show_current_cycle: extract_boolean(params, "show_current_cycle"), + cycle_status_filter: extract_cycle_status_filter(params), + boolean_filters: extract_boolean_filters(params) + } + end + + defp split_member_fields(member_fields) do + selectable = Enum.filter(member_fields, fn f -> f in @domain_member_field_strings end) + computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end) + {selectable, computed} + end + + defp extract_boolean(params, key) do + case Map.get(params, key) do + true -> true + "true" -> true + _ -> false + end + end + + defp extract_cycle_status_filter(params) do + case Map.get(params, "cycle_status_filter") do + "paid" -> :paid + "unpaid" -> :unpaid + _ -> nil + end + end + + defp extract_boolean_filters(params) do + case Map.get(params, "boolean_filters") do + map when is_map(map) -> + map + |> Enum.filter(fn {k, v} -> is_binary(k) and is_boolean(v) end) + |> Enum.filter(fn {k, _} -> match?({:ok, _}, Ecto.UUID.cast(k)) end) + |> Enum.into(%{}) + + _ -> + %{} + end + end + + defp extract_list(params, key) do + case Map.get(params, key) do + list when is_list(list) -> list + _ -> [] + end + end + + defp extract_string(params, key) do + case Map.get(params, key) do + s when is_binary(s) -> s + _ -> nil + end + end + + defp extract_sort_order(params) do + case Map.get(params, "sort_order") do + "asc" -> "asc" + "desc" -> "desc" + _ -> nil + end + end + + defp filter_allowed_member_fields(field_list) do + allowlist = MapSet.new(@member_fields_allowlist) + + field_list + |> Enum.filter(fn field -> is_binary(field) and MapSet.member?(allowlist, field) end) + |> Enum.uniq() + end + + defp filter_valid_uuids(id_list) when is_list(id_list) do + id_list + |> Enum.filter(fn id -> + is_binary(id) and match?({:ok, _}, Ecto.UUID.cast(id)) + end) + |> Enum.uniq() + end +end diff --git a/lib/mv/membership/member_export_sort.ex b/lib/mv/membership/member_export_sort.ex new file mode 100644 index 0000000..324fb75 --- /dev/null +++ b/lib/mv/membership/member_export_sort.ex @@ -0,0 +1,44 @@ +defmodule Mv.Membership.MemberExportSort do + @moduledoc """ + Type-stable sort keys for CSV export custom-field sorting. + + Used only by `MvWeb.MemberExportController` when sorting members by a custom field + after load. Nil values sort last in ascending order and first in descending order. + String and email comparison is case-insensitive. + """ + @doc """ + Returns a comparable sort key for (value_type, value). + + - Nil: rank 1 so that in asc order nil sorts last, in desc nil sorts first. + - date: chronological (ISO8601 string). + - boolean: false < true (0 < 1). + - integer: numerical order. + - string / email: case-insensitive (downcased). + + Handles Ash.Union in value; value_type is the custom field's value_type atom. + """ + @spec custom_field_sort_key(:string | :integer | :boolean | :date | :email, term()) :: + {0 | 1, term()} + def custom_field_sort_key(_value_type, nil), do: {1, nil} + + def custom_field_sort_key(value_type, %Ash.Union{value: value, type: _type}) do + custom_field_sort_key(value_type, value) + end + + def custom_field_sort_key(:date, %Date{} = d), do: {0, Date.to_iso8601(d)} + def custom_field_sort_key(:boolean, true), do: {0, 1} + def custom_field_sort_key(:boolean, false), do: {0, 0} + def custom_field_sort_key(:integer, v) when is_integer(v), do: {0, v} + def custom_field_sort_key(:string, v) when is_binary(v), do: {0, String.downcase(v)} + def custom_field_sort_key(:email, v) when is_binary(v), do: {0, String.downcase(v)} + def custom_field_sort_key(_value_type, v), do: {0, to_string(v)} + + @doc """ + Returns true if key_a should sort before key_b for the given order. + + "asc" -> nil last; "desc" -> nil first. No reverse of list needed. + """ + @spec key_lt({0 | 1, term()}, {0 | 1, term()}, String.t()) :: boolean() + def key_lt(key_a, key_b, "asc"), do: key_a < key_b + def key_lt(key_a, key_b, "desc"), do: key_b < key_a +end diff --git a/lib/mv/membership/members_csv.ex b/lib/mv/membership/members_csv.ex new file mode 100644 index 0000000..a0fd463 --- /dev/null +++ b/lib/mv/membership/members_csv.ex @@ -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 diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 9ef8f2b..60f3636 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -179,7 +179,8 @@ defmodule MvWeb.CoreComponents do aria-haspopup="menu" aria-expanded={@open} aria-controls={@id} - class="btn" + aria-label={@button_label} + class="btn focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-base-content/20" phx-click="toggle_dropdown" phx-target={@phx_target} data-testid="dropdown-button" @@ -233,11 +234,12 @@ defmodule MvWeb.CoreComponents do + <.button class="secondary" id="copy-emails-btn" @@ -282,6 +296,7 @@ <:col :let={member} + :if={:membership_fee_status in @member_fields_visible} label={gettext("Membership Fee Status")} > <%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge( diff --git a/lib/mv_web/live/member_live/index/field_visibility.ex b/lib/mv_web/live/member_live/index/field_visibility.ex index 9ba9267..0b0cb67 100644 --- a/lib/mv_web/live/member_live/index/field_visibility.ex +++ b/lib/mv_web/live/member_live/index/field_visibility.ex @@ -18,10 +18,25 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do 1. User-specific selection (from URL/Session/Cookie) 2. Global settings (from database) 3. Default (all fields visible) + + ## Pseudo Member Fields + + Overview-only fields that are not in `Mv.Constants.member_fields()` (e.g. computed/UI-only). + They appear in the field dropdown and in `member_fields_visible` but are not domain attributes. """ alias Mv.Membership.Helpers.VisibilityConfig + # Single UI key for "Membership Fee Status"; only this appears in the dropdown. + @pseudo_member_fields [:membership_fee_status] + + # Export/API may accept this as alias; must not appear in the UI options list. + @export_only_alias :payment_status + + defp overview_member_fields do + Mv.Constants.member_fields() ++ @pseudo_member_fields + end + @doc """ Gets all available fields for selection. @@ -39,7 +54,10 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do """ @spec get_all_available_fields([struct()]) :: [atom() | String.t()] def get_all_available_fields(custom_fields) do - member_fields = Mv.Constants.member_fields() + member_fields = + overview_member_fields() + |> Enum.reject(fn field -> field == @export_only_alias end) + custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}") member_fields ++ custom_field_names @@ -115,6 +133,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do field_selection |> Enum.filter(fn {_field, visible} -> visible end) |> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end) + |> Enum.uniq() end def get_visible_fields(_), do: [] @@ -132,7 +151,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do """ @spec get_visible_member_fields(%{String.t() => boolean()}) :: [atom()] def get_visible_member_fields(field_selection) when is_map(field_selection) do - member_fields = Mv.Constants.member_fields() + member_fields = overview_member_fields() field_selection |> Enum.filter(fn {field_string, visible} -> @@ -140,10 +159,61 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do visible && field_atom in member_fields end) |> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end) + |> Enum.uniq() end def get_visible_member_fields(_), do: [] + @doc """ + Returns the list of computed (UI-only) member field atoms. + + These fields are not in the database; they must not be used for Ash query + select/sort. Use this to filter sort options and validate sort_field. + """ + @spec computed_member_fields() :: [atom()] + def computed_member_fields, do: @pseudo_member_fields + + @doc """ + Visible member fields that are real DB attributes (from `Mv.Constants.member_fields()`). + + Use for query select/sort. Not for rendering column visibility (use + `get_visible_member_fields/1` for that). + """ + @spec get_visible_member_fields_db(%{String.t() => boolean()}) :: [atom()] + def get_visible_member_fields_db(field_selection) when is_map(field_selection) do + db_fields = MapSet.new(Mv.Constants.member_fields()) + + field_selection + |> Enum.filter(fn {field_string, visible} -> + field_atom = to_field_identifier(field_string) + visible && field_atom in db_fields + end) + |> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end) + |> Enum.uniq() + end + + def get_visible_member_fields_db(_), do: [] + + @doc """ + Visible member fields that are computed/UI-only (e.g. membership_fee_status). + + Use for rendering; do not use for query select or sort. + """ + @spec get_visible_member_fields_computed(%{String.t() => boolean()}) :: [atom()] + def get_visible_member_fields_computed(field_selection) when is_map(field_selection) do + computed_set = MapSet.new(@pseudo_member_fields) + + field_selection + |> Enum.filter(fn {field_string, visible} -> + field_atom = to_field_identifier(field_string) + visible && field_atom in computed_set + end) + |> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end) + |> Enum.uniq() + end + + def get_visible_member_fields_computed(_), do: [] + @doc """ Gets visible custom fields from field selection. @@ -176,19 +246,23 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do Map.merge(member_visibility, custom_field_visibility) end - # Gets member field visibility from settings + # Gets member field visibility from settings (domain fields from settings, pseudo fields default true) defp get_member_field_visibility_from_settings(settings) do visibility_config = VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{})) - member_fields = Mv.Constants.member_fields() + domain_fields = Mv.Constants.member_fields() - Enum.reduce(member_fields, %{}, fn field, acc -> - field_string = Atom.to_string(field) - # exit_date defaults to false (hidden), all other fields default to true - default_visibility = if field == :exit_date, do: false, else: true - show_in_overview = Map.get(visibility_config, field, default_visibility) - Map.put(acc, field_string, show_in_overview) + domain_map = + Enum.reduce(domain_fields, %{}, fn field, acc -> + field_string = Atom.to_string(field) + default_visibility = if field == :exit_date, do: false, else: true + show_in_overview = Map.get(visibility_config, field, default_visibility) + Map.put(acc, field_string, show_in_overview) + end) + + Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc -> + Map.put(acc, Atom.to_string(field), true) end) end @@ -203,16 +277,20 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do end) end - # Converts field string to atom (for member fields) or keeps as string (for custom fields) + # Converts field string to atom (for member fields) or keeps as string (for custom fields). + # Maps export-only alias to canonical UI key so only one option controls the column. defp to_field_identifier(field_string) when is_binary(field_string) do if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do field_string else - try do - String.to_existing_atom(field_string) - rescue - ArgumentError -> field_string - end + atom = + try do + String.to_existing_atom(field_string) + rescue + ArgumentError -> field_string + end + + if atom == @export_only_alias, do: :membership_fee_status, else: atom end end diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index b5bc616..97e0642 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -91,6 +91,7 @@ defmodule MvWeb.Router do # Import/Export (Admin only) live "/admin/import-export", ImportExportLive + post "/members/export.csv", MemberExportController, :export post "/set_locale", LocaleController, :set_locale end diff --git a/lib/mv_web/translations/member_fields.ex b/lib/mv_web/translations/member_fields.ex index 26f55ac..83ab139 100644 --- a/lib/mv_web/translations/member_fields.ex +++ b/lib/mv_web/translations/member_fields.ex @@ -28,6 +28,7 @@ defmodule MvWeb.Translations.MemberFields do def label(:house_number), do: gettext("House Number") def label(:postal_code), do: gettext("Postal Code") def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date") + def label(:membership_fee_status), do: gettext("Membership Fee Status") # Fallback for unknown fields def label(field) do diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 6ba8022..e594bc9 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2318,52 +2318,20 @@ msgstr "Mitgliederdaten verwalten" msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning." msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV Datei. Datenfelder müssen in Mila bereits angelegt sein, damit sie importiert werden können. sie müssen in der Liste der Mitgliederdaten als Datenfeld enthalten sein (z.B. E-Mail). Spalten mit unbekannten Spaltenüberschriften werden mit einer Warnung ignoriert." -#: lib/mv/membership/member/validations/email_change_permission.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Only administrators or the linked user can change the email for members linked to users" -msgstr "Nur Administrator*innen oder die verknüpfte Benutzer*in können die E-Mail von Mitgliedern ändern, die mit Benutzer*innen verknüpft sind." +#~ #: lib/mv_web/live/global_settings_live.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Custom Fields in CSV Import" +#~ msgstr "Benutzerdefinierte Felder" -#: lib/mv_web/live/user_live/form.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Select role..." -msgstr "Keine auswählen" +#~ #: lib/mv_web/live/global_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Failed to prepare CSV import: %{error}" +#~ msgstr "Das Vorbereiten des CSV Imports ist gescheitert: %{error}" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "You are not allowed to perform this action." -msgstr "Du hast keine Berechtigung, diese Aktion auszuführen." - -#: lib/mv_web/live/member_live/form.ex -#, elixir-autogen, elixir-format -msgid "Select a membership fee type" -msgstr "Mitgliedsbeitragstyp auswählen" - -#: lib/mv_web/live/user_live/index.html.heex -#: lib/mv_web/live/user_live/show.ex -#, elixir-autogen, elixir-format -msgid "Linked" -msgstr "Verknüpft" - -#: lib/mv_web/live/user_live/index.html.heex -#: lib/mv_web/live/user_live/show.ex -#, elixir-autogen, elixir-format -msgid "OIDC" -msgstr "OIDC" - -#: lib/mv_web/live/user_live/show.ex -#, elixir-autogen, elixir-format -msgid "Not linked" -msgstr "Nicht verknüpft" - -#: lib/mv_web/live/user_live/form.ex -#, elixir-autogen, elixir-format -msgid "SSO / OIDC user" -msgstr "SSO-/OIDC-Benutzer*in" - -#: lib/mv_web/live/user_live/form.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT." -msgstr "Dieser*e Benutzer*in ist per SSO (Single Sign-On) angebunden. Ein hier gesetztes oder geändertes Passwort betrifft nur die Anmeldung mit E-Mail und Passwort in dieser Anwendung. Es ändert nicht das Passwort beim Identity-Provider (z. B. Authentik). Zum Ändern des SSO-Passworts nutzen Sie den Identity-Provider oder die IT Ihrer Organisation." +#~ #: lib/mv_web/live/global_settings_live.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Individual data fields must be created in Mila before importing. Use the field name as the CSV column header. Unknown custom field columns will be ignored with a warning." +#~ msgstr "Individuelle Datenfelder müssen in Mila erstellt werden, bevor sie importiert werden können. Verwende den Namen des Datenfeldes als CSV-Spaltenüberschrift. Unbekannte Spaltenüberschriften werden mit einer Warnung ignoriert." #~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex #~ #, elixir-autogen, elixir-format diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index ace001a..fb02166 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2319,6 +2319,21 @@ msgstr "" msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning." msgstr "" +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Export members to CSV" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Export to CSV" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "all" +msgstr "" + #: lib/mv/membership/member/validations/email_change_permission.ex #, elixir-autogen, elixir-format msgid "Only administrators or the linked user can change the email for members linked to users" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 510909c..7608376 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2319,52 +2319,35 @@ msgstr "" msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning." msgstr "" -#: lib/mv/membership/member/validations/email_change_permission.ex +#: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format, fuzzy -msgid "Only administrators or the linked user can change the email for members linked to users" -msgstr "Only administrators or the linked user can change the email for members linked to users" - -#: lib/mv_web/live/user_live/form.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Select role..." +msgid "Export members to CSV" msgstr "" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format -msgid "You are not allowed to perform this action." +msgid "Export to CSV" msgstr "" -#: lib/mv_web/live/member_live/form.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Select a membership fee type" -msgstr "" - -#: lib/mv_web/live/user_live/index.html.heex -#: lib/mv_web/live/user_live/show.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Linked" -msgstr "" - -#: lib/mv_web/live/user_live/index.html.heex -#: lib/mv_web/live/user_live/show.ex +#: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format -msgid "OIDC" +msgid "all" msgstr "" -#: lib/mv_web/live/user_live/show.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Not linked" -msgstr "" +#~ #: lib/mv_web/live/global_settings_live.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Custom Fields in CSV Import" +#~ msgstr "" -#: lib/mv_web/live/user_live/form.ex -#, elixir-autogen, elixir-format -msgid "SSO / OIDC user" -msgstr "" +#~ #: lib/mv_web/live/global_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Failed to prepare CSV import: %{error}" +#~ msgstr "" -#: lib/mv_web/live/user_live/form.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT." -msgstr "" +#~ #: lib/mv_web/live/global_settings_live.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Individual data fields must be created in Mila before importing. Use the field name as the CSV column header. Unknown custom field columns will be ignored with a warning." +#~ msgstr "" #~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex #~ #, elixir-autogen, elixir-format diff --git a/test/mv/membership/member_export_sort_test.exs b/test/mv/membership/member_export_sort_test.exs new file mode 100644 index 0000000..812a386 --- /dev/null +++ b/test/mv/membership/member_export_sort_test.exs @@ -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 diff --git a/test/mv/membership/members_csv_test.exs b/test/mv/membership/members_csv_test.exs new file mode 100644 index 0000000..a8688bf --- /dev/null +++ b/test/mv/membership/members_csv_test.exs @@ -0,0 +1,293 @@ +defmodule Mv.Membership.MembersCSVTest do + use ExUnit.Case, async: true + + alias Mv.Membership.MembersCSV + + describe "export/2" do + test "returns CSV with header and one data row (member fields only)" do + member = %{first_name: "Jane", email: "jane@example.com"} + + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Email", kind: :member_field, key: "email"} + ] + + iodata = MembersCSV.export([member], columns) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "First Name" + assert csv =~ "Email" + assert csv =~ "Jane" + assert csv =~ "jane@example.com" + lines = String.split(csv, "\n", trim: true) + assert length(lines) == 2 + end + + test "header uses display labels not raw field names (regression guard)" do + member = %{first_name: "Jane", email: "jane@example.com"} + + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Email", kind: :member_field, key: "email"} + ] + + iodata = MembersCSV.export([member], columns) + csv = IO.iodata_to_binary(iodata) + header_line = csv |> String.split("\n", trim: true) |> hd() + + assert header_line =~ "First Name" + assert header_line =~ "Email" + refute header_line =~ "first_name" + refute header_line =~ "email" + end + + test "escapes cell containing comma (RFC 4180 quoted)" do + member = %{first_name: "Doe, John", email: "john@example.com"} + + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Email", kind: :member_field, key: "email"} + ] + + iodata = MembersCSV.export([member], columns) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ ~s("Doe, John") + assert csv =~ "john@example.com" + end + + test "escapes cell containing double-quote (RFC 4180 doubled and quoted)" do + member = %{first_name: ~s(He said "Hi"), email: "a@b.com"} + + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Email", kind: :member_field, key: "email"} + ] + + iodata = MembersCSV.export([member], columns) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ ~s("He said ""Hi""") + assert csv =~ "a@b.com" + end + + test "formats date as ISO8601 for member fields" do + member = %{first_name: "D", email: "d@d.com", join_date: ~D[2024-03-15]} + + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Email", kind: :member_field, key: "email"}, + %{header: "Join Date", kind: :member_field, key: "join_date"} + ] + + iodata = MembersCSV.export([member], columns) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "2024-03-15" + assert csv =~ "Join Date" + end + + test "formats nil as empty string" do + member = %{first_name: "Only", last_name: nil, email: "x@y.com"} + + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Last Name", kind: :member_field, key: "last_name"}, + %{header: "Email", kind: :member_field, key: "email"} + ] + + iodata = MembersCSV.export([member], columns) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "First Name" + assert csv =~ "Only" + assert csv =~ "x@y.com" + assert csv =~ "Only,,x@y" + end + + test "custom field column uses header and formats value" do + custom_cf = %{id: "cf-1", name: "Active", value_type: :boolean} + + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Email", kind: :member_field, key: "email"}, + %{header: "Active", kind: :custom_field, key: "cf-1", custom_field: custom_cf} + ] + + member = %{ + first_name: "Test", + email: "e@e.com", + custom_field_values: [ + %{custom_field_id: "cf-1", value: true, custom_field: custom_cf} + ] + } + + iodata = MembersCSV.export([member], columns) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "Active" + assert csv =~ "Yes" + end + + test "custom field uses display_name when present, else name" do + custom_cf = %{id: "cf-a", name: "FieldA", value_type: :string} + + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{ + header: "Display Label", + kind: :custom_field, + key: "cf-a", + custom_field: Map.put(custom_cf, :display_name, "Display Label") + } + ] + + member = %{ + first_name: "X", + email: "x@x.com", + custom_field_values: [ + %{custom_field_id: "cf-a", value: "only_a", custom_field: custom_cf} + ] + } + + iodata = MembersCSV.export([member], columns) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "Display Label" + assert csv =~ "only_a" + end + + test "missing custom field value yields empty cell" do + cf1 = %{id: "cf-a", name: "FieldA", value_type: :string} + cf2 = %{id: "cf-b", name: "FieldB", value_type: :string} + + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Email", kind: :member_field, key: "email"}, + %{header: "FieldA", kind: :custom_field, key: "cf-a", custom_field: cf1}, + %{header: "FieldB", kind: :custom_field, key: "cf-b", custom_field: cf2} + ] + + member = %{ + first_name: "X", + email: "x@x.com", + custom_field_values: [%{custom_field_id: "cf-a", value: "only_a", custom_field: cf1}] + } + + iodata = MembersCSV.export([member], columns) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "First Name,Email,FieldA,FieldB" + assert csv =~ "only_a" + assert csv =~ "X,x@x.com,only_a," + end + + test "computed column exports membership fee status label" do + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Email", kind: :member_field, key: "email"}, + %{header: "Membership Fee Status", kind: :computed, key: :membership_fee_status} + ] + + member = %{first_name: "M", email: "m@m.com", membership_fee_status: "Paid"} + + iodata = MembersCSV.export([member], columns) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "Membership Fee Status" + assert csv =~ "Paid" + assert csv =~ "M,m@m.com,Paid" + end + + test "computed column with payment_status key exports same value (alias)" do + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Membership Fee Status", kind: :computed, key: :payment_status} + ] + + member = %{first_name: "X", payment_status: "Unpaid"} + + iodata = MembersCSV.export([member], columns) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "Membership Fee Status" + assert csv =~ "Unpaid" + assert csv =~ "X,Unpaid" + end + + test "CSV injection: formula-like and dangerous prefixes are escaped with apostrophe" do + member = %{ + first_name: "=SUM(A1:A10)", + last_name: "+1", + email: "@cmd|evil" + } + + custom_cf = %{id: "cf-1", name: "Note", value_type: :string} + + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Last Name", kind: :member_field, key: "last_name"}, + %{header: "Email", kind: :member_field, key: "email"}, + %{header: "Note", kind: :custom_field, key: "cf-1", custom_field: custom_cf} + ] + + member_with_cf = + Map.put(member, :custom_field_values, [ + %{custom_field_id: "cf-1", value: "normal text", custom_field: custom_cf} + ]) + + iodata = MembersCSV.export([member_with_cf], columns) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "'=SUM(A1:A10)" + assert csv =~ "'+1" + assert csv =~ "'@cmd|evil" + assert csv =~ "normal text" + refute csv =~ ",'normal text" + end + + test "CSV injection: minus and tab prefix are escaped" do + member = %{first_name: "-2", last_name: "\tleading", email: "safe@x.com"} + + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Last Name", kind: :member_field, key: "last_name"}, + %{header: "Email", kind: :member_field, key: "email"} + ] + + iodata = MembersCSV.export([member], columns) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "'-2" + assert csv =~ "'\tleading" + assert csv =~ "safe@x.com" + end + + test "column order is preserved (headers and values)" do + cf1 = %{id: "a", name: "Custom1", value_type: :string} + cf2 = %{id: "b", name: "Custom2", value_type: :string} + + columns = [ + %{header: "First Name", kind: :member_field, key: "first_name"}, + %{header: "Email", kind: :member_field, key: "email"}, + %{header: "Custom2", kind: :custom_field, key: "b", custom_field: cf2}, + %{header: "Custom1", kind: :custom_field, key: "a", custom_field: cf1} + ] + + member = %{ + first_name: "M", + email: "m@m.com", + custom_field_values: [ + %{custom_field_id: "a", value: "v1", custom_field: cf1}, + %{custom_field_id: "b", value: "v2", custom_field: cf2} + ] + } + + iodata = MembersCSV.export([member], columns) + csv = IO.iodata_to_binary(iodata) + + assert csv =~ "First Name,Email,Custom2,Custom1" + assert csv =~ "M,m@m.com,v2,v1" + end + end +end diff --git a/test/mv_web/components/search_bar_component_test.exs b/test/mv_web/components/search_bar_component_test.exs index bc8bc46..1043c1f 100644 --- a/test/mv_web/components/search_bar_component_test.exs +++ b/test/mv_web/components/search_bar_component_test.exs @@ -15,19 +15,19 @@ defmodule MvWeb.Components.SearchBarComponentTest do {:ok, view, _html} = live(conn, "/members") # simulate search input and check that other members are not listed - html = + _html = view |> element("form[role=search]") |> render_submit(%{"query" => "Friedrich"}) - refute html =~ "Greta" + refute has_element?(view, "input[data-testid='search-input'][value='Greta']") - html = + _html = view |> element("form[role=search]") |> render_submit(%{"query" => "Greta"}) - refute html =~ "Friedrich" + refute has_element?(view, "input[data-testid='search-input'][value='Friedrich']") end end end diff --git a/test/mv_web/controllers/member_export_controller_test.exs b/test/mv_web/controllers/member_export_controller_test.exs new file mode 100644 index 0000000..34f5a75 --- /dev/null +++ b/test/mv_web/controllers/member_export_controller_test.exs @@ -0,0 +1,243 @@ +defmodule MvWeb.MemberExportControllerTest do + use MvWeb.ConnCase, async: true + + alias Mv.Fixtures + + defp csrf_token_from_conn(conn) do + get_session(conn, "_csrf_token") || csrf_token_from_html(response(conn, 200)) + end + + defp csrf_token_from_html(html) when is_binary(html) do + case Regex.run(~r/name="csrf-token"\s+content="([^"]+)"/, html) do + [_, token] -> token + _ -> nil + end + end + + describe "POST /members/export.csv" do + setup %{conn: conn} do + # Create 3 members for export tests + m1 = + Fixtures.member_fixture(%{ + first_name: "Alice", + last_name: "One", + email: "alice.one@example.com" + }) + + m2 = + Fixtures.member_fixture(%{ + first_name: "Bob", + last_name: "Two", + email: "bob.two@example.com" + }) + + m3 = + Fixtures.member_fixture(%{ + first_name: "Carol", + last_name: "Three", + email: "carol.three@example.com" + }) + + %{member1: m1, member2: m2, member3: m3, conn: conn} + end + + test "selected export: returns 200, text/csv, header + exactly 2 data rows", %{ + conn: conn, + member1: m1, + member2: m2 + } do + payload = %{ + "selected_ids" => [m1.id, m2.id], + "member_fields" => ["first_name", "last_name", "email"], + "custom_field_ids" => [], + "query" => nil, + "sort_field" => nil, + "sort_order" => nil + } + + conn = get(conn, "/members") + csrf_token = csrf_token_from_conn(conn) + + conn = + post(conn, "/members/export.csv", %{ + "payload" => Jason.encode!(payload), + "_csrf_token" => csrf_token + }) + + assert conn.status == 200 + assert get_resp_header(conn, "content-type") |> List.first() =~ "text/csv" + + body = response(conn, 200) + lines = String.split(body, "\n", trim: true) + + # Header + 2 data rows (headers are localized labels) + assert length(lines) == 3 + assert hd(lines) =~ "First Name" + assert hd(lines) =~ "Email" + assert body =~ "Alice" + assert body =~ "Bob" + refute body =~ "Carol" + end + + test "all export: selected_ids=[] returns all members (at least 3 data rows)", %{ + conn: conn, + member1: _m1, + member2: _m2, + member3: _m3 + } do + payload = %{ + "selected_ids" => [], + "member_fields" => ["first_name", "email"], + "custom_field_ids" => [], + "query" => nil, + "sort_field" => nil, + "sort_order" => nil + } + + conn = get(conn, "/members") + csrf_token = csrf_token_from_conn(conn) + + conn = + post(conn, "/members/export.csv", %{ + "payload" => Jason.encode!(payload), + "_csrf_token" => csrf_token + }) + + assert conn.status == 200 + body = response(conn, 200) + lines = String.split(body, "\n", trim: true) + + # Header + at least 3 data rows (headers are localized labels) + assert length(lines) >= 4 + assert hd(lines) =~ "First Name" + assert body =~ "Alice" + assert body =~ "Bob" + assert body =~ "Carol" + end + + test "whitelist: unknown member_fields are not in header", %{conn: conn, member1: m1} do + payload = %{ + "selected_ids" => [m1.id], + "member_fields" => ["first_name", "unknown_field", "email"], + "custom_field_ids" => [], + "query" => nil, + "sort_field" => nil, + "sort_order" => nil + } + + conn = get(conn, "/members") + csrf_token = csrf_token_from_conn(conn) + + conn = + post(conn, "/members/export.csv", %{ + "payload" => Jason.encode!(payload), + "_csrf_token" => csrf_token + }) + + assert conn.status == 200 + body = response(conn, 200) + header = body |> String.split("\n", trim: true) |> hd() + + assert header =~ "First Name" + assert header =~ "Email" + refute header =~ "unknown_field" + end + + test "export includes membership_fee_status column when requested", %{ + conn: conn, + member1: m1 + } do + payload = %{ + "selected_ids" => [m1.id], + "member_fields" => ["first_name", "membership_fee_status"], + "custom_field_ids" => [], + "query" => nil, + "sort_field" => nil, + "sort_order" => nil + } + + conn = get(conn, "/members") + csrf_token = csrf_token_from_conn(conn) + + conn = + post(conn, "/members/export.csv", %{ + "payload" => Jason.encode!(payload), + "_csrf_token" => csrf_token + }) + + assert conn.status == 200 + body = response(conn, 200) + header = body |> String.split("\n", trim: true) |> hd() + + assert header =~ "First Name" + assert header =~ "Membership Fee Status" + assert body =~ "Alice" + end + + test "export with payment_status alias: header shows Membership Fee Status", %{ + conn: conn, + member1: m1 + } do + payload = %{ + "selected_ids" => [m1.id], + "member_fields" => ["first_name", "payment_status"], + "custom_field_ids" => [], + "query" => nil, + "sort_field" => nil, + "sort_order" => nil + } + + conn = get(conn, "/members") + csrf_token = csrf_token_from_conn(conn) + + conn = + post(conn, "/members/export.csv", %{ + "payload" => Jason.encode!(payload), + "_csrf_token" => csrf_token + }) + + assert conn.status == 200 + body = response(conn, 200) + header = body |> String.split("\n", trim: true) |> hd() + + assert header =~ "Membership Fee Status" + assert body =~ "Alice" + end + + test "export with show_current_cycle: membership fee status column exists stably", %{ + conn: conn, + member1: _m1, + member2: _m2, + member3: _m3 + } do + payload = %{ + "selected_ids" => [], + "member_fields" => ["first_name", "email", "membership_fee_status"], + "custom_field_ids" => [], + "query" => nil, + "sort_field" => nil, + "sort_order" => nil, + "show_current_cycle" => true + } + + conn = get(conn, "/members") + csrf_token = csrf_token_from_conn(conn) + + conn = + post(conn, "/members/export.csv", %{ + "payload" => Jason.encode!(payload), + "_csrf_token" => csrf_token + }) + + assert conn.status == 200 + body = response(conn, 200) + lines = String.split(body, "\n", trim: true) + + assert length(lines) >= 4 + header = hd(lines) + assert header =~ "First Name" + assert header =~ "Email" + assert header =~ "Membership Fee Status" + end + end +end diff --git a/test/mv_web/live/import_export_live_test.exs b/test/mv_web/live/import_export_live_test.exs index a165ea6..653cd8d 100644 --- a/test/mv_web/live/import_export_live_test.exs +++ b/test/mv_web/live/import_export_live_test.exs @@ -19,6 +19,7 @@ defmodule MvWeb.ImportExportLiveTest do end describe "Import/Export LiveView" do + @describetag :ui setup %{conn: conn} do admin_user = Mv.Fixtures.user_with_role_fixture("admin") conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user) @@ -45,6 +46,7 @@ defmodule MvWeb.ImportExportLiveTest do end describe "CSV Import Section" do + @describetag :ui setup %{conn: conn} do admin_user = Mv.Fixtures.user_with_role_fixture("admin") conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user) @@ -524,6 +526,7 @@ defmodule MvWeb.ImportExportLiveTest do # Verified by import-results-panel existence above end + @tag :ui test "A11y: file input has label", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/admin/import-export") @@ -532,6 +535,7 @@ defmodule MvWeb.ImportExportLiveTest do html =~ ~r/]*>.*CSV File/i end + @tag :ui test "A11y: status/progress container has aria-live", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/admin/import-export") @@ -540,6 +544,7 @@ defmodule MvWeb.ImportExportLiveTest do assert html =~ ~r/aria-live=["']polite["']/i end + @tag :ui test "A11y: links have descriptive text", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/admin/import-export") @@ -642,6 +647,7 @@ defmodule MvWeb.ImportExportLiveTest do html =~ "Failed to prepare" end + @tag :ui test "wrong file type (.txt): upload shows error", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/admin/import-export") @@ -659,6 +665,7 @@ defmodule MvWeb.ImportExportLiveTest do assert html =~ "CSV" or html =~ "csv" or html =~ ".csv" end + @tag :ui test "file input has correct accept attribute for CSV only", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/admin/import-export") diff --git a/test/mv_web/member_live/form_error_handling_test.exs b/test/mv_web/member_live/form_error_handling_test.exs index b2bf804..9e55cd8 100644 --- a/test/mv_web/member_live/form_error_handling_test.exs +++ b/test/mv_web/member_live/form_error_handling_test.exs @@ -9,6 +9,7 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do require Ash.Query describe "error handling - flash messages" do + @describetag :ui test "shows flash message when member creation fails with validation error", %{conn: conn} do system_actor = Mv.Helpers.SystemActor.get_system_actor() diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 3234761..9d4a429 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -46,78 +46,76 @@ defmodule MvWeb.MemberLive.IndexTest do |> Ash.create!(actor: actor) end - test "shows translated title in German", %{conn: conn} do - conn = conn_with_oidc_user(conn) - conn = Plug.Test.init_test_session(conn, locale: "de") - {:ok, _view, html} = live(conn, "/members") - # Expected German title - assert html =~ "Mitglieder" - end + describe "translations" do + @describetag :ui - test "shows translated title in English", %{conn: conn} do - conn = conn_with_oidc_user(conn) - Gettext.put_locale(MvWeb.Gettext, "en") - {:ok, _view, html} = live(conn, "/members") - # Expected English title - assert html =~ "Members" - end + test "shows translated title and button text by locale", %{conn: conn} do + locales = [ + {"de", "Mitglieder", "Speichern", + fn c -> Plug.Test.init_test_session(c, locale: "de") end}, + {"en", "Members", "Save", + fn c -> + Gettext.put_locale(MvWeb.Gettext, "en") + c + end} + ] - test "shows translated button text in German", %{conn: conn} do - conn = conn_with_oidc_user(conn) - conn = Plug.Test.init_test_session(conn, locale: "de") - {:ok, _view, html} = live(conn, "/members/new") - assert html =~ "Speichern" - end + for {_locale, expected_title, expected_button, set_locale} <- locales do + base = conn_with_oidc_user(conn) |> set_locale.() - test "shows translated button text in English", %{conn: conn} do - conn = conn_with_oidc_user(conn) - Gettext.put_locale(MvWeb.Gettext, "en") - {:ok, _view, html} = live(conn, "/members/new") - assert html =~ "Save" - end + {:ok, _view, index_html} = live(base, "/members") + assert index_html =~ expected_title - test "shows translated flash message after creating a member in German", %{conn: conn} do - conn = conn_with_oidc_user(conn) - conn = Plug.Test.init_test_session(conn, locale: "de") - {:ok, form_view, _html} = live(conn, "/members/new") + base_form = conn_with_oidc_user(conn) |> set_locale.() + {:ok, _view, form_html} = live(base_form, "/members/new") + assert form_html =~ expected_button + end + end - form_data = %{ - "member[first_name]" => "Max", - "member[last_name]" => "Mustermann", - "member[email]" => "max@example.com" - } + test "shows translated flash message after creating a member in German", %{conn: conn} do + conn = conn_with_oidc_user(conn) + conn = Plug.Test.init_test_session(conn, locale: "de") + {:ok, form_view, _html} = live(conn, "/members/new") - # Submit form and follow the redirect to get the flash message - {:ok, index_view, _html} = - form_view - |> form("#member-form", form_data) - |> render_submit() - |> follow_redirect(conn, "/members") + form_data = %{ + "member[first_name]" => "Max", + "member[last_name]" => "Mustermann", + "member[email]" => "max@example.com" + } - assert has_element?(index_view, "#flash-group", "Mitglied wurde erfolgreich erstellt") - end + # Submit form and follow the redirect to get the flash message + {:ok, index_view, _html} = + form_view + |> form("#member-form", form_data) + |> render_submit() + |> follow_redirect(conn, "/members") - test "shows translated flash message after creating a member in English", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, form_view, _html} = live(conn, "/members/new") + assert has_element?(index_view, "#flash-group", "Mitglied wurde erfolgreich erstellt") + end - form_data = %{ - "member[first_name]" => "Max", - "member[last_name]" => "Mustermann", - "member[email]" => "max@example.com" - } + test "shows translated flash message after creating a member in English", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, form_view, _html} = live(conn, "/members/new") - # Submit form and follow the redirect to get the flash message - {:ok, index_view, _html} = - form_view - |> form("#member-form", form_data) - |> render_submit() - |> follow_redirect(conn, "/members") + form_data = %{ + "member[first_name]" => "Max", + "member[last_name]" => "Mustermann", + "member[email]" => "max@example.com" + } - assert has_element?(index_view, "#flash-group", "Member created successfully") + # Submit form and follow the redirect to get the flash message + {:ok, index_view, _html} = + form_view + |> form("#member-form", form_data) + |> render_submit() + |> follow_redirect(conn, "/members") + + assert has_element?(index_view, "#flash-group", "Member created successfully") + end end describe "sorting integration" do + @describetag :ui test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") @@ -200,6 +198,7 @@ defmodule MvWeb.MemberLive.IndexTest do end describe "URL param handling" do + @describetag :ui test "handle_params reads sort query and applies it", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc") @@ -226,6 +225,7 @@ defmodule MvWeb.MemberLive.IndexTest do end describe "search and sort integration" do + @describetag :ui test "search maintains sort state", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc") @@ -253,6 +253,7 @@ defmodule MvWeb.MemberLive.IndexTest do end end + @tag :ui test "handle_info(:search_changed) updates assigns with search results", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") @@ -521,6 +522,50 @@ defmodule MvWeb.MemberLive.IndexTest do end end + describe "export to CSV" do + setup do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, m1} = + Mv.Membership.create_member( + %{first_name: "Export", last_name: "One", email: "export1@example.com"}, + actor: system_actor + ) + + %{member1: m1} + end + + test "export button is rendered when no selection and shows (all)", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Button text shows "all" when 0 selected (locale-dependent) + assert html =~ "Export to CSV" + assert html =~ "all" or html =~ "All" + end + + test "after select_member event export button shows (1)", %{conn: conn, member1: member1} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + render_click(view, "select_member", %{"id" => member1.id}) + + html = render(view) + assert html =~ "Export to CSV" + assert html =~ "(1)" + end + + test "form has correct action and payload hidden input", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + assert html =~ "/members/export.csv" + assert html =~ ~s(name="payload") + assert html =~ ~s(type="hidden") + assert html =~ ~s(name="_csrf_token") + end + end + describe "cycle status filter" do # Helper to create a member (only used in this describe block) defp create_member(attrs, actor) do @@ -780,6 +825,7 @@ defmodule MvWeb.MemberLive.IndexTest do |> Ash.create!(actor: system_actor) end + @tag :ui test "mount initializes boolean_custom_field_filters as empty map", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") @@ -788,6 +834,7 @@ defmodule MvWeb.MemberLive.IndexTest do assert state.socket.assigns.boolean_custom_field_filters == %{} end + @tag :ui test "mount initializes boolean_custom_fields as empty list when no boolean fields exist", %{ conn: conn } do @@ -1762,6 +1809,7 @@ defmodule MvWeb.MemberLive.IndexTest do refute html_false =~ "NoValue" end + @tag :ui test "boolean custom field appears in filter dropdown after being added", %{conn: conn} do conn = conn_with_oidc_user(conn)