WIP: Implements CSV export closes #285 #408
7 changed files with 487 additions and 1 deletions
|
|
@ -158,8 +158,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
|
||||||
|
|
@ -208,6 +209,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",
|
||||||
|
|
|
||||||
55
lib/mv/membership/custom_field_value_formatter.ex
Normal file
55
lib/mv/membership/custom_field_value_formatter.ex
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
defmodule Mv.Membership.CustomFieldValueFormatter do
|
||||||
|
@moduledoc """
|
||||||
|
Neutral formatter for custom field values (e.g. CSV export).
|
||||||
|
|
||||||
|
Same logic as the member overview Formatter but without Gettext or web helpers,
|
||||||
|
so it can be used from the Membership context. For boolean: "Yes"/"No";
|
||||||
|
for date: European format (dd.mm.yyyy).
|
||||||
|
"""
|
||||||
|
@doc """
|
||||||
|
Formats a custom field value for plain text (e.g. CSV).
|
||||||
|
|
||||||
|
Handles nil, Ash.Union, JSONB map, and direct values. Uses custom_field.value_type
|
||||||
|
for typing. Boolean -> "Yes"/"No", Date -> dd.mm.yyyy.
|
||||||
|
"""
|
||||||
|
def format_custom_field_value(nil, _custom_field), do: ""
|
||||||
|
|
||||||
|
def format_custom_field_value(%Ash.Union{value: value, type: type}, custom_field) do
|
||||||
|
format_value_by_type(value, type, custom_field)
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_custom_field_value(value, custom_field) when is_map(value) do
|
||||||
|
type = Map.get(value, "type") || Map.get(value, "_union_type")
|
||||||
|
val = Map.get(value, "value") || Map.get(value, "_union_value")
|
||||||
|
format_value_by_type(val, type, custom_field)
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_custom_field_value(value, custom_field) do
|
||||||
|
format_value_by_type(value, custom_field.value_type, custom_field)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_value_by_type(value, :string, _), do: to_string(value)
|
||||||
|
defp format_value_by_type(value, :integer, _), do: to_string(value)
|
||||||
|
|
||||||
|
defp format_value_by_type(value, type, _) when type in [:string, :email] and is_binary(value) do
|
||||||
|
if String.trim(value) == "", do: "", else: value
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_value_by_type(value, :email, _), do: to_string(value)
|
||||||
|
defp format_value_by_type(value, :boolean, _) when value == true, do: "Yes"
|
||||||
|
defp format_value_by_type(value, :boolean, _) when value == false, do: "No"
|
||||||
|
defp format_value_by_type(value, :boolean, _), do: to_string(value)
|
||||||
|
|
||||||
|
defp format_value_by_type(%Date{} = date, :date, _) do
|
||||||
|
Calendar.strftime(date, "%d.%m.%Y")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_value_by_type(value, :date, _) when is_binary(value) do
|
||||||
|
case Date.from_iso8601(value) do
|
||||||
|
{:ok, date} -> Calendar.strftime(date, "%d.%m.%Y")
|
||||||
|
_ -> value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_value_by_type(value, _type, _), do: to_string(value)
|
||||||
|
end
|
||||||
91
lib/mv/membership/members_csv.ex
Normal file
91
lib/mv/membership/members_csv.ex
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
alias Mv.Membership.CustomFieldValueFormatter
|
||||||
|
alias NimbleCSV.RFC4180
|
||||||
|
|
||||||
|
@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.
|
||||||
|
|
||||||
|
Returns iodata suitable for `IO.iodata_to_binary/1` or sending as response body.
|
||||||
|
"""
|
||||||
|
@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))
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
format_member_value(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp member_field_key(field_name) when is_binary(field_name) do
|
||||||
|
try do
|
||||||
|
String.to_existing_atom(field_name)
|
||||||
|
rescue
|
||||||
|
ArgumentError -> field_name
|
||||||
|
end
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
291
lib/mv_web/controllers/member_export_controller.ex
Normal file
291
lib/mv_web/controllers/member_export_controller.ex
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
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.Membership.Member
|
||||||
|
alias Mv.Membership.CustomField
|
||||||
|
alias Mv.Membership.MembersCSV
|
||||||
|
alias Mv.Authorization.Actor
|
||||||
|
|
||||||
|
@member_fields_allowlist Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||||
|
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||||
|
|
||||||
|
def export(conn, params) do
|
||||||
|
actor = current_actor(conn)
|
||||||
|
if is_nil(actor), do: return_forbidden(conn)
|
||||||
|
|
||||||
|
case params["payload"] do
|
||||||
|
nil ->
|
||||||
|
conn
|
||||||
|
|> put_status(400)
|
||||||
|
|> put_resp_content_type("application/json")
|
||||||
|
|> json(%{error: "payload required"})
|
||||||
|
|
||||||
|
payload when is_binary(payload) ->
|
||||||
|
case Jason.decode(payload) do
|
||||||
|
{:ok, decoded} when is_map(decoded) ->
|
||||||
|
parsed = parse_and_validate(decoded)
|
||||||
|
run_export(conn, actor, parsed)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
conn
|
||||||
|
|> put_status(400)
|
||||||
|
|> put_resp_content_type("application/json")
|
||||||
|
|> json(%{error: "invalid JSON"})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp current_actor(conn) do
|
||||||
|
conn.assigns[:current_user]
|
||||||
|
|> Actor.ensure_loaded()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp return_forbidden(conn) do
|
||||||
|
conn
|
||||||
|
|> put_status(403)
|
||||||
|
|> put_resp_content_type("application/json")
|
||||||
|
|> json(%{error: "Forbidden"})
|
||||||
|
|> halt()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_and_validate(params) do
|
||||||
|
%{
|
||||||
|
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
|
||||||
|
member_fields: filter_allowed_member_fields(extract_list(params, "member_fields")),
|
||||||
|
custom_field_ids: filter_valid_uuids(extract_list(params, "custom_field_ids")),
|
||||||
|
query: extract_string(params, "query"),
|
||||||
|
sort_field: extract_string(params, "sort_field"),
|
||||||
|
sort_order: extract_sort_order(params)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp 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
|
||||||
|
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
|
||||||
|
csv_iodata = MembersCSV.export(members, parsed.member_fields, custom_fields_by_id)
|
||||||
|
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 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 load_members_for_export(actor, parsed, custom_fields_by_id) do
|
||||||
|
select_fields = [:id] ++ Enum.map(parsed.member_fields, &String.to_existing_atom/1)
|
||||||
|
|
||||||
|
query =
|
||||||
|
Member
|
||||||
|
|> Ash.Query.new()
|
||||||
|
|> Ash.Query.select(select_fields)
|
||||||
|
|> load_custom_field_values_query(parsed.custom_field_ids)
|
||||||
|
|
||||||
|
query =
|
||||||
|
if parsed.selected_ids != [] do
|
||||||
|
Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
|
||||||
|
else
|
||||||
|
query
|
||||||
|
|> apply_search_export(parsed.query)
|
||||||
|
|> then(fn q ->
|
||||||
|
{q, _sort_after_load} = maybe_sort_export(q, parsed.sort_field, parsed.sort_order)
|
||||||
|
q
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
case Ash.read(query, actor: actor) do
|
||||||
|
{:ok, members} ->
|
||||||
|
members =
|
||||||
|
if parsed.selected_ids == [] and sort_after_load?(parsed.sort_field) do
|
||||||
|
sort_members_by_custom_field_export(
|
||||||
|
members,
|
||||||
|
parsed.sort_field,
|
||||||
|
parsed.sort_order,
|
||||||
|
Map.values(custom_fields_by_id)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
# selected_ids != []: no sort. selected_ids == [] and DB sort: already in query.
|
||||||
|
members
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, members}
|
||||||
|
|
||||||
|
{:error, %Ash.Error.Forbidden{}} ->
|
||||||
|
{:error, :forbidden}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_custom_field_values_query(query, []), do: query
|
||||||
|
|
||||||
|
defp load_custom_field_values_query(query, custom_field_ids) do
|
||||||
|
cfv_query =
|
||||||
|
Mv.Membership.CustomFieldValue
|
||||||
|
|> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids))
|
||||||
|
|> Ash.Query.load(custom_field: [:id, :name, :value_type])
|
||||||
|
|
||||||
|
Ash.Query.load(query, custom_field_values: cfv_query)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_search_export(query, nil), do: query
|
||||||
|
defp apply_search_export(query, ""), do: query
|
||||||
|
|
||||||
|
defp apply_search_export(query, q) when is_binary(q) do
|
||||||
|
if String.trim(q) != "" do
|
||||||
|
Member.fuzzy_search(query, %{query: q})
|
||||||
|
else
|
||||||
|
query
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_sort_export(query, nil, _order), do: {query, false}
|
||||||
|
defp maybe_sort_export(query, _field, nil), do: {query, false}
|
||||||
|
|
||||||
|
defp maybe_sort_export(query, field, order) when is_binary(field) do
|
||||||
|
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_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
|
||||||
|
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
|
||||||
|
|
||||||
|
extract_sort_val = fn member ->
|
||||||
|
cfv = find_cfv(member, custom_field)
|
||||||
|
if cfv, do: extract_sort_value(cfv.value, custom_field.value_type), else: nil
|
||||||
|
end
|
||||||
|
|
||||||
|
sorted =
|
||||||
|
members
|
||||||
|
|> Enum.sort_by(extract_sort_val, fn
|
||||||
|
nil, _ -> false
|
||||||
|
_, nil -> true
|
||||||
|
a, b -> if order == "desc", do: a >= b, else: a <= b
|
||||||
|
end)
|
||||||
|
|
||||||
|
if order == "desc", do: Enum.reverse(sorted), else: sorted
|
||||||
|
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 extract_sort_value(%Ash.Union{value: value, type: type}, _),
|
||||||
|
do: extract_sort_value(value, type)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
|
||||||
|
end
|
||||||
|
|
@ -131,6 +131,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
)
|
)
|
||||||
|> assign(:show_current_cycle, false)
|
|> assign(:show_current_cycle, false)
|
||||||
|> assign(:membership_fee_status_filter, nil)
|
|> assign(:membership_fee_status_filter, nil)
|
||||||
|
|> assign_export_payload()
|
||||||
|
|
||||||
# We call handle params to use the query from the URL
|
# We call handle params to use the query from the URL
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
|
|
@ -1729,5 +1730,36 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> assign(:selected_count, selected_count)
|
|> assign(:selected_count, selected_count)
|
||||||
|> assign(:any_selected?, any_selected?)
|
|> assign(:any_selected?, any_selected?)
|
||||||
|> assign(:mailto_bcc, mailto_bcc)
|
|> assign(:mailto_bcc, mailto_bcc)
|
||||||
|
|> assign_export_payload()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Builds the export payload map and assigns :export_payload_json for the CSV export form.
|
||||||
|
# Called when selection, visible fields, query, or sort change so the form always has current data.
|
||||||
|
defp assign_export_payload(socket) do
|
||||||
|
payload = build_export_payload(socket)
|
||||||
|
assign(socket, :export_payload_json, Jason.encode!(payload))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_export_payload(socket) do
|
||||||
|
member_fields_visible = socket.assigns[:member_fields_visible] || []
|
||||||
|
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
||||||
|
|
||||||
|
%{
|
||||||
|
selected_ids: socket.assigns.selected_members |> MapSet.to_list(),
|
||||||
|
member_fields: Enum.map(member_fields_visible, &Atom.to_string/1),
|
||||||
|
custom_field_ids: visible_custom_field_ids,
|
||||||
|
query: socket.assigns[:query] || nil,
|
||||||
|
sort_field: export_sort_field(socket.assigns[:sort_field]),
|
||||||
|
sort_order: export_sort_order(socket.assigns[:sort_order])
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp export_sort_field(nil), do: nil
|
||||||
|
defp export_sort_field(f) when is_atom(f), do: Atom.to_string(f)
|
||||||
|
defp export_sort_field(f) when is_binary(f), do: f
|
||||||
|
|
||||||
|
defp export_sort_order(nil), do: nil
|
||||||
|
defp export_sort_order(:asc), do: "asc"
|
||||||
|
defp export_sort_order(:desc), do: "desc"
|
||||||
|
defp export_sort_order(o) when is_binary(o), do: o
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue