WIP: Implements CSV export closes #285 #408
17 changed files with 960 additions and 67 deletions
|
|
@ -158,8 +158,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
|
||||
|
|
@ -208,6 +209,7 @@ defmodule Mv.Authorization.PermissionSets do
|
|||
"/users/:id/edit",
|
||||
"/users/:id/show/edit",
|
||||
"/members",
|
||||
"/members/export.csv",
|
||||
# Create member
|
||||
"/members/new",
|
||||
"/members/:id",
|
||||
|
|
|
|||
55
lib/mv/membership/custom_field_value_formatter.ex
Normal file
55
lib/mv/membership/custom_field_value_formatter.ex
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
defmodule Mv.Membership.CustomFieldValueFormatter do
|
||||
@moduledoc """
|
||||
Neutral formatter for custom field values (e.g. CSV export).
|
||||
|
||||
Same logic as the member overview Formatter but without Gettext or web helpers,
|
||||
so it can be used from the Membership context. For boolean: "Yes"/"No";
|
||||
for date: European format (dd.mm.yyyy).
|
||||
"""
|
||||
@doc """
|
||||
Formats a custom field value for plain text (e.g. CSV).
|
||||
|
||||
Handles nil, Ash.Union, JSONB map, and direct values. Uses custom_field.value_type
|
||||
for typing. Boolean -> "Yes"/"No", Date -> dd.mm.yyyy.
|
||||
"""
|
||||
def format_custom_field_value(nil, _custom_field), do: ""
|
||||
|
||||
def format_custom_field_value(%Ash.Union{value: value, type: type}, custom_field) do
|
||||
format_value_by_type(value, type, custom_field)
|
||||
end
|
||||
|
||||
def format_custom_field_value(value, custom_field) when is_map(value) do
|
||||
type = Map.get(value, "type") || Map.get(value, "_union_type")
|
||||
val = Map.get(value, "value") || Map.get(value, "_union_value")
|
||||
format_value_by_type(val, type, custom_field)
|
||||
end
|
||||
|
||||
def format_custom_field_value(value, custom_field) do
|
||||
format_value_by_type(value, custom_field.value_type, custom_field)
|
||||
end
|
||||
|
||||
defp format_value_by_type(value, :string, _), do: to_string(value)
|
||||
defp format_value_by_type(value, :integer, _), do: to_string(value)
|
||||
|
||||
defp format_value_by_type(value, type, _) when type in [:string, :email] and is_binary(value) do
|
||||
if String.trim(value) == "", do: "", else: value
|
||||
end
|
||||
|
||||
defp format_value_by_type(value, :email, _), do: to_string(value)
|
||||
defp format_value_by_type(value, :boolean, _) when value == true, do: "Yes"
|
||||
defp format_value_by_type(value, :boolean, _) when value == false, do: "No"
|
||||
defp format_value_by_type(value, :boolean, _), do: to_string(value)
|
||||
|
||||
defp format_value_by_type(%Date{} = date, :date, _) do
|
||||
Calendar.strftime(date, "%d.%m.%Y")
|
||||
end
|
||||
|
||||
defp format_value_by_type(value, :date, _) when is_binary(value) do
|
||||
case Date.from_iso8601(value) do
|
||||
{:ok, date} -> Calendar.strftime(date, "%d.%m.%Y")
|
||||
_ -> value
|
||||
end
|
||||
end
|
||||
|
||||
defp format_value_by_type(value, _type, _), do: to_string(value)
|
||||
end
|
||||
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
|
||||
297
lib/mv_web/controllers/member_export_controller.ex
Normal file
297
lib/mv_web/controllers/member_export_controller.ex
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
defmodule MvWeb.MemberExportController do
|
||||
@moduledoc """
|
||||
Controller for CSV export of members.
|
||||
|
||||
POST /members/export.csv with form param "payload" (JSON string).
|
||||
Same permission and actor context as the member overview; 403 if unauthorized.
|
||||
"""
|
||||
use MvWeb, :controller
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
alias Mv.Authorization.Actor
|
||||
alias Mv.Membership.CustomField
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.Membership.MembersCSV
|
||||
|
||||
@member_fields_allowlist Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||
|
||||
def export(conn, params) do
|
||||
actor = current_actor(conn)
|
||||
if is_nil(actor), do: return_forbidden(conn)
|
||||
|
||||
case params["payload"] do
|
||||
nil ->
|
||||
conn
|
||||
|> put_status(400)
|
||||
|> put_resp_content_type("application/json")
|
||||
|> json(%{error: "payload required"})
|
||||
|
||||
payload when is_binary(payload) ->
|
||||
case Jason.decode(payload) do
|
||||
{:ok, decoded} when is_map(decoded) ->
|
||||
parsed = parse_and_validate(decoded)
|
||||
run_export(conn, actor, parsed)
|
||||
|
||||
_ ->
|
||||
conn
|
||||
|> put_status(400)
|
||||
|> put_resp_content_type("application/json")
|
||||
|> json(%{error: "invalid JSON"})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp current_actor(conn) do
|
||||
conn.assigns[:current_user]
|
||||
|> Actor.ensure_loaded()
|
||||
end
|
||||
|
||||
defp return_forbidden(conn) do
|
||||
conn
|
||||
|> put_status(403)
|
||||
|> put_resp_content_type("application/json")
|
||||
|> json(%{error: "Forbidden"})
|
||||
|> halt()
|
||||
end
|
||||
|
||||
defp parse_and_validate(params) do
|
||||
%{
|
||||
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
|
||||
member_fields: filter_allowed_member_fields(extract_list(params, "member_fields")),
|
||||
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])
|
||||
|
||||
query
|
||||
|> Ash.read(actor: actor)
|
||||
|> handle_custom_fields_read_result(custom_field_ids)
|
||||
end
|
||||
|
||||
defp handle_custom_fields_read_result({:ok, custom_fields}, custom_field_ids) do
|
||||
by_id = build_custom_fields_by_id(custom_field_ids, custom_fields)
|
||||
{:ok, by_id}
|
||||
end
|
||||
|
||||
defp handle_custom_fields_read_result({:error, %Ash.Error.Forbidden{}}, _custom_field_ids) do
|
||||
{:error, :forbidden}
|
||||
end
|
||||
|
||||
defp build_custom_fields_by_id(custom_field_ids, custom_fields) do
|
||||
Enum.reduce(custom_field_ids, %{}, fn id, acc ->
|
||||
find_and_add_custom_field(acc, id, custom_fields)
|
||||
end)
|
||||
end
|
||||
|
||||
defp find_and_add_custom_field(acc, id, custom_fields) do
|
||||
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do
|
||||
nil -> acc
|
||||
cf -> Map.put(acc, id, cf)
|
||||
end
|
||||
end
|
||||
|
||||
defp load_members_for_export(actor, parsed, custom_fields_by_id) do
|
||||
select_fields = [:id] ++ Enum.map(parsed.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
|
||||
|
|
@ -642,24 +642,48 @@ defmodule MvWeb.ImportExportLive do
|
|||
# Start async task to process chunk in production
|
||||
# Use start_child for fire-and-forget: no monitor, no Task messages
|
||||
# We only use our own send/2 messages for communication
|
||||
Task.Supervisor.start_child(Mv.TaskSupervisor, fn ->
|
||||
# Set locale in task process for translations
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
||||
process_chunk_with_error_handling(
|
||||
Task.Supervisor.start_child(
|
||||
Mv.TaskSupervisor,
|
||||
build_chunk_processing_task(
|
||||
chunk,
|
||||
import_state.column_map,
|
||||
import_state.custom_field_map,
|
||||
opts,
|
||||
live_view_pid,
|
||||
idx
|
||||
idx,
|
||||
locale
|
||||
)
|
||||
)
|
||||
end)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Builds the task function for processing a chunk asynchronously.
|
||||
defp build_chunk_processing_task(
|
||||
chunk,
|
||||
column_map,
|
||||
custom_field_map,
|
||||
opts,
|
||||
live_view_pid,
|
||||
idx,
|
||||
locale
|
||||
) do
|
||||
fn ->
|
||||
# Set locale in task process for translations
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
||||
process_chunk_with_error_handling(
|
||||
chunk,
|
||||
column_map,
|
||||
custom_field_map,
|
||||
opts,
|
||||
live_view_pid,
|
||||
idx
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Handles chunk processing result from async task and schedules the next chunk.
|
||||
@spec handle_chunk_result(
|
||||
Phoenix.LiveView.Socket.t(),
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
)
|
||||
|> assign(:show_current_cycle, false)
|
||||
|> assign(:membership_fee_status_filter, nil)
|
||||
|> assign_export_payload()
|
||||
|
||||
# We call handle params to use the query from the URL
|
||||
{:ok, socket}
|
||||
|
|
@ -1729,5 +1730,36 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> assign(:selected_count, selected_count)
|
||||
|> assign(:any_selected?, any_selected?)
|
||||
|> assign(:mailto_bcc, mailto_bcc)
|
||||
|> assign_export_payload()
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,20 @@
|
|||
<.header>
|
||||
{gettext("Members")}
|
||||
<:actions>
|
||||
<form method="post" action={~p"/members/export.csv"} target="_blank" class="inline">
|
||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||
<input type="hidden" name="payload" value={@export_payload_json} />
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-secondary gap-2"
|
||||
aria-label={gettext("Export members to CSV")}
|
||||
>
|
||||
<.icon name="hero-arrow-down-tray" />
|
||||
{gettext("Export to CSV")} ({if @selected_count == 0,
|
||||
do: gettext("all"),
|
||||
else: @selected_count})
|
||||
</button>
|
||||
</form>
|
||||
<.button
|
||||
class="secondary"
|
||||
id="copy-emails-btn"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -2319,6 +2319,21 @@ 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_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export members to CSV"
|
||||
msgstr "Mitglieder importieren (CSV)"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export to CSV"
|
||||
msgstr "Als CSV exportieren"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "all"
|
||||
msgstr "alle"
|
||||
|
||||
#~ #: lib/mv_web/live/global_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Custom Fields in CSV Import"
|
||||
|
|
|
|||
|
|
@ -2319,3 +2319,18 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
|
||||
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 ""
|
||||
|
|
|
|||
|
|
@ -2320,6 +2320,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, fuzzy
|
||||
msgid "Export members to CSV"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export to CSV"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "all"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/global_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Custom Fields in CSV Import"
|
||||
|
|
|
|||
124
test/mv/membership/members_csv_test.exs
Normal file
124
test/mv/membership/members_csv_test.exs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
defmodule Mv.Membership.MembersCSVTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Mv.Membership.MembersCSV
|
||||
|
||||
describe "export/3" do
|
||||
test "returns CSV with header and one data row (member fields only)" do
|
||||
member = %{first_name: "Jane", email: "jane@example.com"}
|
||||
member_fields = ["first_name", "email"]
|
||||
custom_fields_by_id = %{}
|
||||
|
||||
iodata = MembersCSV.export([member], member_fields, custom_fields_by_id)
|
||||
csv = IO.iodata_to_binary(iodata)
|
||||
|
||||
assert csv =~ "first_name"
|
||||
assert csv =~ "email"
|
||||
assert csv =~ "Jane"
|
||||
assert csv =~ "jane@example.com"
|
||||
# One header line, one data line
|
||||
lines = String.split(csv, "\n", trim: true)
|
||||
assert length(lines) == 2
|
||||
end
|
||||
|
||||
test "escapes cell containing comma (RFC 4180 quoted)" do
|
||||
member = %{first_name: "Doe, John", email: "john@example.com"}
|
||||
member_fields = ["first_name", "email"]
|
||||
custom_fields_by_id = %{}
|
||||
|
||||
iodata = MembersCSV.export([member], member_fields, custom_fields_by_id)
|
||||
csv = IO.iodata_to_binary(iodata)
|
||||
|
||||
# Comma inside value must be quoted so the cell is one field
|
||||
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"}
|
||||
member_fields = ["first_name", "email"]
|
||||
custom_fields_by_id = %{}
|
||||
|
||||
iodata = MembersCSV.export([member], member_fields, custom_fields_by_id)
|
||||
csv = IO.iodata_to_binary(iodata)
|
||||
|
||||
# Double-quote inside value must be doubled and cell quoted
|
||||
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]}
|
||||
iodata = MembersCSV.export([member], ["first_name", "email", "join_date"], %{})
|
||||
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"}
|
||||
member_fields = ["first_name", "last_name", "email"]
|
||||
custom_fields_by_id = %{}
|
||||
|
||||
iodata = MembersCSV.export([member], member_fields, custom_fields_by_id)
|
||||
csv = IO.iodata_to_binary(iodata)
|
||||
|
||||
assert csv =~ "first_name"
|
||||
assert csv =~ "Only"
|
||||
assert csv =~ "x@y.com"
|
||||
# Nil becomes empty; between Only and x@y we have empty (e.g. Only,,x@y.com)
|
||||
assert csv =~ "Only,,x@y"
|
||||
end
|
||||
|
||||
test "formats boolean as true/false" do
|
||||
# Use a field we can set to boolean via a custom-like struct - member has no boolean field.
|
||||
# So we test via custom field instead.
|
||||
custom_cf = %{id: "cf-1", name: "Active", value_type: :boolean}
|
||||
custom_fields_by_id = %{"cf-1" => custom_cf}
|
||||
|
||||
member_with_cfv = %{
|
||||
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_with_cfv],
|
||||
["first_name", "email"],
|
||||
custom_fields_by_id
|
||||
)
|
||||
|
||||
csv = IO.iodata_to_binary(iodata)
|
||||
assert csv =~ "Active"
|
||||
# Formatter yields "Yes" for true (gettext)
|
||||
assert csv =~ "Yes"
|
||||
end
|
||||
|
||||
test "includes custom field columns in header and rows (order from map)" do
|
||||
cf1 = %{id: "a", name: "Custom1", value_type: :string}
|
||||
cf2 = %{id: "b", name: "Custom2", value_type: :string}
|
||||
# Map order: a then b
|
||||
custom_fields_by_id = %{"a" => cf1, "b" => cf2}
|
||||
|
||||
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], ["first_name", "email"], custom_fields_by_id)
|
||||
csv = IO.iodata_to_binary(iodata)
|
||||
|
||||
assert csv =~ "first_name,email,Custom1,Custom2"
|
||||
assert csv =~ "v1"
|
||||
assert csv =~ "v2"
|
||||
end
|
||||
end
|
||||
end
|
||||
146
test/mv_web/controllers/member_export_controller_test.exs
Normal file
146
test/mv_web/controllers/member_export_controller_test.exs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
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
|
||||
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
|
||||
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
|
||||
end
|
||||
end
|
||||
|
|
@ -19,6 +19,7 @@ defmodule MvWeb.ImportExportLiveTest do
|
|||
end
|
||||
|
||||
describe "Import/Export LiveView" do
|
||||
@describetag :ui
|
||||
setup %{conn: conn} do
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
||||
|
|
@ -45,6 +46,7 @@ defmodule MvWeb.ImportExportLiveTest do
|
|||
end
|
||||
|
||||
describe "CSV Import Section" do
|
||||
@describetag :ui
|
||||
setup %{conn: conn} do
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
||||
|
|
@ -524,6 +526,7 @@ defmodule MvWeb.ImportExportLiveTest do
|
|||
# Verified by import-results-panel existence above
|
||||
end
|
||||
|
||||
@tag :ui
|
||||
test "A11y: file input has label", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
|
|
@ -532,6 +535,7 @@ defmodule MvWeb.ImportExportLiveTest do
|
|||
html =~ ~r/<label[^>]*>.*CSV File/i
|
||||
end
|
||||
|
||||
@tag :ui
|
||||
test "A11y: status/progress container has aria-live", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
|
|
@ -540,6 +544,7 @@ defmodule MvWeb.ImportExportLiveTest do
|
|||
assert html =~ ~r/aria-live=["']polite["']/i
|
||||
end
|
||||
|
||||
@tag :ui
|
||||
test "A11y: links have descriptive text", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
|
|
@ -642,6 +647,7 @@ defmodule MvWeb.ImportExportLiveTest do
|
|||
html =~ "Failed to prepare"
|
||||
end
|
||||
|
||||
@tag :ui
|
||||
test "wrong file type (.txt): upload shows error", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
|
|
@ -659,6 +665,7 @@ defmodule MvWeb.ImportExportLiveTest do
|
|||
assert html =~ "CSV" or html =~ "csv" or html =~ ".csv"
|
||||
end
|
||||
|
||||
@tag :ui
|
||||
test "file input has correct accept attribute for CSV only", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ defmodule MvWeb.ProfileNavigationTest do
|
|||
end
|
||||
|
||||
@tag :skip
|
||||
# TODO: Implement user initials in navbar avatar - see issue #170
|
||||
# Note: User initials in navbar avatar - see issue #170
|
||||
test "shows user initials in avatar", %{conn: conn} do
|
||||
# Setup: Create and login a user
|
||||
user = create_test_user(%{email: "test.user@example.com"})
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
|
|||
require Ash.Query
|
||||
|
||||
describe "error handling - flash messages" do
|
||||
@describetag :ui
|
||||
test "shows flash message when member creation fails with validation error", %{conn: conn} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|> Ash.create!(actor: actor)
|
||||
end
|
||||
|
||||
describe "translations" do
|
||||
@describetag :ui
|
||||
test "shows translated title in German", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = Plug.Test.init_test_session(conn, locale: "de")
|
||||
|
|
@ -116,8 +118,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|
||||
assert has_element?(index_view, "#flash-group", "Member created successfully")
|
||||
end
|
||||
end
|
||||
|
||||
describe "sorting integration" do
|
||||
@describetag :ui
|
||||
test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
|
@ -200,6 +204,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 +231,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 +259,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 +528,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 +831,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 +840,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 +1815,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
refute html_false =~ "NoValue"
|
||||
end
|
||||
|
||||
@tag :ui
|
||||
test "boolean custom field appears in filter dropdown after being added", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue