fix: sorting and filter for export

This commit is contained in:
carla 2026-02-05 15:03:25 +01:00
parent e7d63b9b0a
commit 9b9e7ec995
10 changed files with 1013 additions and 714 deletions

View file

@ -2,9 +2,11 @@ defmodule Mv.Membership.MembersCSV do
@moduledoc """
Exports members to CSV (RFC 4180) as iodata.
Uses NimbleCSV.RFC4180 for encoding. Member fields are formatted as strings;
custom field values use the same formatting logic as the member overview (neutral formatter).
Column order for custom fields follows the key order of the `custom_fields_by_id` map.
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
@ -12,57 +14,82 @@ defmodule Mv.Membership.MembersCSV do
@doc """
Exports a list of members to CSV iodata.
- `members` - List of member structs (with optional `custom_field_values` loaded)
- `member_fields` - List of member field names (strings, e.g. `["first_name", "email"]`)
- `custom_fields_by_id` - Map of custom_field_id => %CustomField{}. Key order defines column order.
- `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()],
[String.t()],
%{optional(String.t() | Ecto.UUID.t()) => struct()}
) :: iodata()
def export(members, member_fields, custom_fields_by_id) when is_list(members) do
custom_entries = custom_field_entries(custom_fields_by_id)
header = build_header(member_fields, custom_entries)
rows = Enum.map(members, &build_row(&1, member_fields, custom_entries))
@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 custom_field_entries(by_id) when is_map(by_id) do
Enum.map(by_id, fn {id, cf} -> {to_string(id), cf} end)
defp build_header(columns) do
columns
|> Enum.map(fn col -> col.header end)
|> Enum.map(&safe_cell/1)
end
defp build_header(member_fields, custom_entries) do
member_headers = member_fields
custom_headers = Enum.map(custom_entries, fn {_id, cf} -> cf.name end)
member_headers ++ custom_headers
defp build_row(member, columns) do
columns
|> Enum.map(fn col -> cell_value(member, col) end)
|> Enum.map(&safe_cell/1)
end
defp build_row(member, member_fields, custom_entries) do
member_cells = Enum.map(member_fields, &format_member_field(member, &1))
custom_cells =
Enum.map(custom_entries, fn {id, cf} -> format_custom_field(member, id, cf) end)
member_cells ++ custom_cells
end
defp format_member_field(member, field_name) do
key = member_field_key(field_name)
value = Map.get(member, key)
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 member_field_key(field_name) when is_binary(field_name) do
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(field_name)
String.to_existing_atom(k)
rescue
ArgumentError -> field_name
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"
@ -70,22 +97,4 @@ defmodule Mv.Membership.MembersCSV do
defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
defp format_member_value(value), do: to_string(value)
defp format_custom_field(member, custom_field_id, custom_field) do
cfv = find_custom_field_value(member, custom_field_id)
if cfv,
do: CustomFieldValueFormatter.format_custom_field_value(cfv.value, custom_field),
else: ""
end
defp find_custom_field_value(member, custom_field_id) do
values = Map.get(member, :custom_field_values) || []
id_str = to_string(custom_field_id)
Enum.find(values, fn cfv ->
to_string(cfv.custom_field_id) == id_str or
(Map.get(cfv, :custom_field) && to_string(cfv.custom_field.id) == id_str)
end)
end
end