Merge branch 'main' into feat/299_plz
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
commit
9a7608f9a1
16 changed files with 573 additions and 88 deletions
|
|
@ -16,7 +16,7 @@ defmodule Mv.Membership.MemberExport do
|
||||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||||
|
|
||||||
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
||||||
["membership_fee_status", "groups"]
|
["membership_fee_type", "membership_fee_status", "groups"]
|
||||||
@computed_export_fields ["membership_fee_status"]
|
@computed_export_fields ["membership_fee_status"]
|
||||||
@computed_insert_after "membership_fee_start_date"
|
@computed_insert_after "membership_fee_start_date"
|
||||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||||
|
|
@ -326,10 +326,10 @@ defmodule Mv.Membership.MemberExport do
|
||||||
# Separate groups from other fields (groups is handled as a special field, not a member field)
|
# Separate groups from other fields (groups is handled as a special field, not a member field)
|
||||||
groups_field = if "groups" in member_fields, do: ["groups"], else: []
|
groups_field = if "groups" in member_fields, do: ["groups"], else: []
|
||||||
|
|
||||||
# final member_fields list (used for column specs order): table order + computed inserted + groups
|
# final member_fields list (used for column specs order): table order + fee type + computed + groups
|
||||||
ordered_member_fields =
|
ordered_member_fields =
|
||||||
selectable_member_fields
|
selectable_member_fields
|
||||||
|> insert_computed_fields_like_table(computed_fields)
|
|> insert_fee_type_and_computed_fields_like_table(computed_fields, member_fields)
|
||||||
|> then(fn fields -> fields ++ groups_field end)
|
|> then(fn fields -> fields ++ groups_field end)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
|
|
@ -420,25 +420,50 @@ defmodule Mv.Membership.MemberExport do
|
||||||
table_order |> Enum.filter(&(&1 in fields))
|
table_order |> Enum.filter(&(&1 in fields))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp insert_computed_fields_like_table(db_fields_ordered, computed_fields) do
|
defp insert_fee_type_and_computed_fields_like_table(
|
||||||
# Insert membership_fee_status right after membership_fee_start_date (if both selected),
|
db_fields_ordered,
|
||||||
# otherwise append at the end of DB fields.
|
computed_fields,
|
||||||
|
member_fields
|
||||||
|
) do
|
||||||
computed_fields = computed_fields || []
|
computed_fields = computed_fields || []
|
||||||
|
member_fields = member_fields || []
|
||||||
|
|
||||||
db_with_insert =
|
db_with_insert =
|
||||||
Enum.flat_map(db_fields_ordered, fn f ->
|
Enum.flat_map(db_fields_ordered, fn f ->
|
||||||
if f == @computed_insert_after and "membership_fee_status" in computed_fields do
|
expand_field_with_computed(f, member_fields, computed_fields)
|
||||||
[f, "membership_fee_status"]
|
end)
|
||||||
|
|
||||||
|
# If fee type is visible but start_date was not in the list, it won't be in db_with_insert
|
||||||
|
db_with_insert =
|
||||||
|
if "membership_fee_type" in member_fields and "membership_fee_type" not in db_with_insert do
|
||||||
|
db_with_insert ++ ["membership_fee_type"]
|
||||||
|
else
|
||||||
|
db_with_insert
|
||||||
|
end
|
||||||
|
|
||||||
|
remaining = Enum.reject(computed_fields, &(&1 in db_with_insert))
|
||||||
|
db_with_insert ++ remaining
|
||||||
|
end
|
||||||
|
|
||||||
|
# Insert membership_fee_type and membership_fee_status after membership_fee_start_date (table order).
|
||||||
|
defp expand_field_with_computed(f, member_fields, computed_fields) do
|
||||||
|
if f == @computed_insert_after do
|
||||||
|
extra = []
|
||||||
|
|
||||||
|
extra =
|
||||||
|
if "membership_fee_type" in member_fields,
|
||||||
|
do: extra ++ ["membership_fee_type"],
|
||||||
|
else: extra
|
||||||
|
|
||||||
|
extra =
|
||||||
|
if "membership_fee_status" in computed_fields,
|
||||||
|
do: extra ++ ["membership_fee_status"],
|
||||||
|
else: extra
|
||||||
|
|
||||||
|
[f] ++ extra
|
||||||
else
|
else
|
||||||
[f]
|
[f]
|
||||||
end
|
end
|
||||||
end)
|
|
||||||
|
|
||||||
remaining =
|
|
||||||
computed_fields
|
|
||||||
|> Enum.reject(&(&1 in db_with_insert))
|
|
||||||
|
|
||||||
db_with_insert ++ remaining
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp normalize_computed_fields(fields) when is_list(fields) do
|
defp normalize_computed_fields(fields) when is_list(fields) do
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,10 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
|
|
||||||
need_groups = "groups" in parsed.member_fields
|
need_groups = "groups" in parsed.member_fields
|
||||||
|
|
||||||
|
need_membership_fee_type =
|
||||||
|
"membership_fee_type" in parsed.member_fields or
|
||||||
|
parsed.sort_field == "membership_fee_type"
|
||||||
|
|
||||||
query =
|
query =
|
||||||
Member
|
Member
|
||||||
|> Ash.Query.new()
|
|> Ash.Query.new()
|
||||||
|
|
@ -141,6 +145,7 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
|> load_custom_field_values_query(custom_field_ids_union)
|
|> load_custom_field_values_query(custom_field_ids_union)
|
||||||
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
||||||
|> maybe_load_groups(need_groups)
|
|> maybe_load_groups(need_groups)
|
||||||
|
|> maybe_load_membership_fee_type(need_membership_fee_type)
|
||||||
|
|
||||||
query =
|
query =
|
||||||
if parsed.selected_ids != [] do
|
if parsed.selected_ids != [] do
|
||||||
|
|
@ -196,8 +201,10 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
defp sort_members_in_memory(members, field, order) when is_binary(field) do
|
defp sort_members_in_memory(members, field, order) when is_binary(field) do
|
||||||
field_atom = String.to_existing_atom(field)
|
field_atom = String.to_existing_atom(field)
|
||||||
|
|
||||||
if field_atom in Mv.Constants.member_fields() do
|
if field_atom in Mv.Constants.member_fields() or field_atom == :membership_fee_type do
|
||||||
sort_by_field(members, field_atom, order)
|
key_fn = sort_key_fn_for_field(field_atom)
|
||||||
|
compare_fn = build_compare_fn(order)
|
||||||
|
Enum.sort_by(members, key_fn, compare_fn)
|
||||||
else
|
else
|
||||||
members
|
members
|
||||||
end
|
end
|
||||||
|
|
@ -207,12 +214,16 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
|
|
||||||
defp sort_members_in_memory(members, _field, _order), do: members
|
defp sort_members_in_memory(members, _field, _order), do: members
|
||||||
|
|
||||||
defp sort_by_field(members, field_atom, order) do
|
defp sort_key_fn_for_field(:membership_fee_type) do
|
||||||
key_fn = fn member -> Map.get(member, field_atom) end
|
fn member ->
|
||||||
compare_fn = build_compare_fn(order)
|
case Map.get(member, :membership_fee_type) do
|
||||||
|
nil -> nil
|
||||||
Enum.sort_by(members, key_fn, compare_fn)
|
rel -> Map.get(rel, :name)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sort_key_fn_for_field(field_atom), do: fn member -> Map.get(member, field_atom) end
|
||||||
|
|
||||||
defp build_compare_fn("asc"), do: fn a, b -> a <= b end
|
defp build_compare_fn("asc"), do: fn a, b -> a <= b end
|
||||||
defp build_compare_fn("desc"), do: fn a, b -> b <= a end
|
defp build_compare_fn("desc"), do: fn a, b -> b <= a end
|
||||||
|
|
@ -245,26 +256,41 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
|
|
||||||
defp maybe_sort(query, field, order) when is_binary(field) do
|
defp maybe_sort(query, field, order) when is_binary(field) do
|
||||||
cond do
|
cond do
|
||||||
field == "groups" ->
|
field == "groups" -> {query, true}
|
||||||
# Groups sort → in-memory nach dem Read (wie Tabelle)
|
field == "membership_fee_type" -> apply_fee_type_sort(query, order)
|
||||||
{query, true}
|
custom_field_sort?(field) -> {query, true}
|
||||||
|
true -> apply_standard_member_sort(query, field, order)
|
||||||
custom_field_sort?(field) ->
|
|
||||||
{query, true}
|
|
||||||
|
|
||||||
true ->
|
|
||||||
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
|
end
|
||||||
rescue
|
rescue
|
||||||
ArgumentError -> {query, false}
|
ArgumentError -> {query, false}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp apply_fee_type_sort(query, order) do
|
||||||
|
order_atom = if order == "desc", do: :desc, else: :asc
|
||||||
|
{Ash.Query.sort(query, [{"membership_fee_type.name", order_atom}]), false}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_standard_member_sort(query, field, order) do
|
||||||
|
field_atom = String.to_existing_atom(field)
|
||||||
|
|
||||||
|
sortable =
|
||||||
|
field_atom in (Mv.Constants.member_fields() -- [:notes]) or
|
||||||
|
field_atom == :membership_fee_type
|
||||||
|
|
||||||
|
if sortable do
|
||||||
|
order_atom = if order == "desc", do: :desc, else: :asc
|
||||||
|
|
||||||
|
sort_field =
|
||||||
|
if field_atom == :membership_fee_type,
|
||||||
|
do: {"membership_fee_type.name", order_atom},
|
||||||
|
else: {field_atom, order_atom}
|
||||||
|
|
||||||
|
{Ash.Query.sort(query, [sort_field]), false}
|
||||||
|
else
|
||||||
|
{query, false}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp sort_members_by_custom_field(members, _field, _order, _custom_fields) when members == [],
|
defp sort_members_by_custom_field(members, _field, _order, _custom_fields) when members == [],
|
||||||
do: []
|
do: []
|
||||||
|
|
||||||
|
|
@ -344,6 +370,12 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
Ash.Query.load(query, groups: [:id, :name])
|
Ash.Query.load(query, groups: [:id, :name])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_load_membership_fee_type(query, false), do: query
|
||||||
|
|
||||||
|
defp maybe_load_membership_fee_type(query, true) do
|
||||||
|
Ash.Query.load(query, membership_fee_type: [:id, :name])
|
||||||
|
end
|
||||||
|
|
||||||
defp apply_cycle_status_filter(members, nil, _show_current), do: members
|
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
|
defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do
|
||||||
|
|
@ -393,6 +425,19 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
membership_fee_type_col =
|
||||||
|
if "membership_fee_type" in parsed.member_fields do
|
||||||
|
[
|
||||||
|
%{
|
||||||
|
key: :membership_fee_type,
|
||||||
|
kind: :membership_fee_type,
|
||||||
|
label: label_fn.(:membership_fee_type)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
groups_col =
|
groups_col =
|
||||||
if "groups" in parsed.member_fields do
|
if "groups" in parsed.member_fields do
|
||||||
[
|
[
|
||||||
|
|
@ -424,7 +469,8 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
end)
|
end)
|
||||||
|> Enum.reject(&is_nil/1)
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|
||||||
member_cols ++ computed_cols ++ groups_col ++ custom_cols
|
# Table order: ... membership_fee_start_date, membership_fee_type, membership_fee_status, groups, custom
|
||||||
|
member_cols ++ membership_fee_type_col ++ computed_cols ++ groups_col ++ custom_cols
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_rows(members, columns, custom_fields_by_id) do
|
defp build_rows(members, columns, custom_fields_by_id) do
|
||||||
|
|
@ -454,6 +500,17 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
if is_binary(value), do: value, else: ""
|
if is_binary(value), do: value, else: ""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp cell_value(
|
||||||
|
member,
|
||||||
|
%{kind: :membership_fee_type, key: :membership_fee_type},
|
||||||
|
_custom_fields_by_id
|
||||||
|
) do
|
||||||
|
case Map.get(member, :membership_fee_type) do
|
||||||
|
%{name: name} when is_binary(name) -> name
|
||||||
|
_ -> ""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp cell_value(member, %{kind: :groups, key: :groups}, _custom_fields_by_id) do
|
defp cell_value(member, %{kind: :groups, key: :groups}, _custom_fields_by_id) do
|
||||||
groups = Map.get(member, :groups) || []
|
groups = Map.get(member, :groups) || []
|
||||||
format_groups(groups)
|
format_groups(groups)
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,13 @@ defmodule Mv.Membership.MembersCSV do
|
||||||
if is_binary(value), do: value, else: ""
|
if is_binary(value), do: value, else: ""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp cell_value(member, %{kind: :membership_fee_type, key: :membership_fee_type}) do
|
||||||
|
case Map.get(member, :membership_fee_type) do
|
||||||
|
%{name: name} when is_binary(name) -> name
|
||||||
|
_ -> ""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp cell_value(member, %{kind: :groups, key: :groups}) do
|
defp cell_value(member, %{kind: :groups, key: :groups}) do
|
||||||
groups = Map.get(member, :groups) || []
|
groups = Map.get(member, :groups) || []
|
||||||
format_groups(groups)
|
format_groups(groups)
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ defmodule MvWeb.MemberExportController do
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
||||||
["groups"]
|
["membership_fee_type", "groups"]
|
||||||
@computed_export_fields ["membership_fee_status"]
|
@computed_export_fields ["membership_fee_status"]
|
||||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||||
|
|
||||||
|
|
@ -239,6 +239,10 @@ defmodule MvWeb.MemberExportController do
|
||||||
|
|
||||||
need_groups = "groups" in parsed.member_fields
|
need_groups = "groups" in parsed.member_fields
|
||||||
|
|
||||||
|
need_membership_fee_type =
|
||||||
|
"membership_fee_type" in parsed.member_fields or
|
||||||
|
parsed.sort_field == "membership_fee_type"
|
||||||
|
|
||||||
query =
|
query =
|
||||||
Member
|
Member
|
||||||
|> Ash.Query.new()
|
|> Ash.Query.new()
|
||||||
|
|
@ -246,6 +250,7 @@ defmodule MvWeb.MemberExportController do
|
||||||
|> load_custom_field_values_query(parsed.custom_field_ids)
|
|> load_custom_field_values_query(parsed.custom_field_ids)
|
||||||
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
||||||
|> maybe_load_groups(need_groups)
|
|> maybe_load_groups(need_groups)
|
||||||
|
|> maybe_load_membership_fee_type(need_membership_fee_type)
|
||||||
|
|
||||||
query =
|
query =
|
||||||
if parsed.selected_ids != [] do
|
if parsed.selected_ids != [] do
|
||||||
|
|
@ -296,6 +301,12 @@ defmodule MvWeb.MemberExportController do
|
||||||
Ash.Query.load(query, groups: [:id, :name])
|
Ash.Query.load(query, groups: [:id, :name])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_load_membership_fee_type(query, false), do: query
|
||||||
|
|
||||||
|
defp maybe_load_membership_fee_type(query, true) do
|
||||||
|
Ash.Query.load(query, membership_fee_type: [:id, :name])
|
||||||
|
end
|
||||||
|
|
||||||
# Adds computed field values to members (e.g. membership_fee_status)
|
# Adds computed field values to members (e.g. membership_fee_status)
|
||||||
defp add_computed_fields(members, computed_fields, show_current_cycle) do
|
defp add_computed_fields(members, computed_fields, show_current_cycle) do
|
||||||
if "membership_fee_status" in computed_fields do
|
if "membership_fee_status" in computed_fields do
|
||||||
|
|
@ -343,26 +354,45 @@ defmodule MvWeb.MemberExportController do
|
||||||
defp maybe_sort_export(query, field, order) when is_binary(field) do
|
defp maybe_sort_export(query, field, order) when is_binary(field) do
|
||||||
cond do
|
cond do
|
||||||
field == "groups" ->
|
field == "groups" ->
|
||||||
# Groups sort → in-memory nach dem Read (wie Tabelle)
|
|
||||||
{query, true}
|
{query, true}
|
||||||
|
|
||||||
|
field == "membership_fee_type" ->
|
||||||
|
apply_membership_fee_type_sort_export(query, order)
|
||||||
|
|
||||||
custom_field_sort?(field) ->
|
custom_field_sort?(field) ->
|
||||||
# Custom field sort → in-memory nach dem Read (wie Tabelle)
|
|
||||||
{query, true}
|
{query, true}
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
field_atom = String.to_existing_atom(field)
|
apply_member_field_sort_export(query, field, order)
|
||||||
|
|
||||||
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
|
end
|
||||||
rescue
|
rescue
|
||||||
ArgumentError -> {query, false}
|
ArgumentError -> {query, false}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp apply_membership_fee_type_sort_export(query, order) do
|
||||||
|
order_atom = if order == "desc", do: :desc, else: :asc
|
||||||
|
{Ash.Query.sort(query, [{"membership_fee_type.name", order_atom}]), false}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_member_field_sort_export(query, field, order) do
|
||||||
|
field_atom = String.to_existing_atom(field)
|
||||||
|
|
||||||
|
sortable =
|
||||||
|
field_atom in (Mv.Constants.member_fields() -- [:notes]) or
|
||||||
|
field_atom == :membership_fee_type
|
||||||
|
|
||||||
|
if sortable do
|
||||||
|
order_atom = if order == "desc", do: :desc, else: :asc
|
||||||
|
|
||||||
|
sort_field =
|
||||||
|
if field_atom == :membership_fee_type, do: :membership_fee_type_id, else: field_atom
|
||||||
|
|
||||||
|
{Ash.Query.sort(query, [{sort_field, order_atom}]), false}
|
||||||
|
else
|
||||||
|
{query, false}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
|
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -488,6 +518,19 @@ defmodule MvWeb.MemberExportController do
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
membership_fee_type_col =
|
||||||
|
if "membership_fee_type" in parsed.member_fields do
|
||||||
|
[
|
||||||
|
%{
|
||||||
|
header: membership_fee_type_field_header(conn),
|
||||||
|
kind: :membership_fee_type,
|
||||||
|
key: :membership_fee_type
|
||||||
|
}
|
||||||
|
]
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
groups_col =
|
groups_col =
|
||||||
if "groups" in parsed.member_fields do
|
if "groups" in parsed.member_fields do
|
||||||
[
|
[
|
||||||
|
|
@ -519,7 +562,8 @@ defmodule MvWeb.MemberExportController do
|
||||||
end)
|
end)
|
||||||
|> Enum.reject(&is_nil/1)
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|
||||||
member_cols ++ computed_cols ++ groups_col ++ custom_cols
|
# Table order: ... membership_fee_start_date, membership_fee_type, membership_fee_status, groups, custom
|
||||||
|
member_cols ++ membership_fee_type_col ++ computed_cols ++ groups_col ++ custom_cols
|
||||||
end
|
end
|
||||||
|
|
||||||
# --- headers: use MemberFields.label for translations ---
|
# --- headers: use MemberFields.label for translations ---
|
||||||
|
|
@ -559,6 +603,10 @@ defmodule MvWeb.MemberExportController do
|
||||||
cf.name
|
cf.name
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp membership_fee_type_field_header(_conn) do
|
||||||
|
MemberFields.label(:membership_fee_type)
|
||||||
|
end
|
||||||
|
|
||||||
defp groups_field_header(_conn) do
|
defp groups_field_header(_conn) do
|
||||||
MemberFields.label(:groups)
|
MemberFields.label(:groups)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@ defmodule MvWeb.MemberPdfExportController do
|
||||||
@invalid_json_message "invalid JSON"
|
@invalid_json_message "invalid JSON"
|
||||||
@export_failed_message "Failed to generate PDF export"
|
@export_failed_message "Failed to generate PDF export"
|
||||||
|
|
||||||
@allowed_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
@allowed_member_field_strings (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
||||||
|
["membership_fee_type", "groups"]
|
||||||
|
|
||||||
def export(conn, %{"payload" => payload}) when is_binary(payload) do
|
def export(conn, %{"payload" => payload}) when is_binary(payload) do
|
||||||
actor = current_actor(conn)
|
actor = current_actor(conn)
|
||||||
|
|
|
||||||
|
|
@ -615,7 +615,9 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_params(params, _url, socket) do
|
def handle_params(params, url, socket) do
|
||||||
|
url = url || request_url_from_socket(socket)
|
||||||
|
params = merge_fields_param_from_uri(params, url)
|
||||||
prev_sig = build_signature(socket)
|
prev_sig = build_signature(socket)
|
||||||
|
|
||||||
fields_in_url? =
|
fields_in_url? =
|
||||||
|
|
@ -625,20 +627,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
|
|
||||||
url_selection = FieldSelection.parse_from_url(params)
|
url_selection = FieldSelection.parse_from_url(params)
|
||||||
|
final_selection = compute_final_field_selection(fields_in_url?, url_selection, socket)
|
||||||
merged_selection =
|
|
||||||
FieldSelection.merge_sources(
|
|
||||||
url_selection,
|
|
||||||
socket.assigns.user_field_selection,
|
|
||||||
%{}
|
|
||||||
)
|
|
||||||
|
|
||||||
final_selection =
|
|
||||||
FieldVisibility.merge_with_global_settings(
|
|
||||||
merged_selection,
|
|
||||||
socket.assigns.settings,
|
|
||||||
socket.assigns.all_custom_fields
|
|
||||||
)
|
|
||||||
|
|
||||||
visible_member_fields =
|
visible_member_fields =
|
||||||
final_selection
|
final_selection
|
||||||
|
|
@ -828,6 +817,70 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
add_boolean_filters(base_params, boolean_filters)
|
add_boolean_filters(base_params, boolean_filters)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp compute_final_field_selection(true, url_selection, socket) do
|
||||||
|
only_url =
|
||||||
|
FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields)
|
||||||
|
|
||||||
|
visible_members = FieldVisibility.get_visible_member_fields(only_url)
|
||||||
|
visible_custom = FieldVisibility.get_visible_custom_fields(only_url)
|
||||||
|
|
||||||
|
if visible_members == [] and visible_custom == [] do
|
||||||
|
# URL had only invalid field names; fall back to session + global.
|
||||||
|
compute_final_field_selection(false, url_selection, socket)
|
||||||
|
else
|
||||||
|
only_url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp compute_final_field_selection(false, url_selection, socket) do
|
||||||
|
merged =
|
||||||
|
FieldSelection.merge_sources(
|
||||||
|
url_selection,
|
||||||
|
socket.assigns.user_field_selection,
|
||||||
|
%{}
|
||||||
|
)
|
||||||
|
|
||||||
|
FieldVisibility.merge_with_global_settings(
|
||||||
|
merged,
|
||||||
|
socket.assigns.settings,
|
||||||
|
socket.assigns.all_custom_fields
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# On full page load conn.params has no query string; read "fields" from URI so column visibility is restored.
|
||||||
|
defp request_url_from_socket(socket) do
|
||||||
|
case socket.private[:connect_info] do
|
||||||
|
%Plug.Conn{} = conn -> Plug.Conn.request_url(conn)
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp merge_fields_param_from_uri(params, nil), do: params
|
||||||
|
|
||||||
|
defp merge_fields_param_from_uri(params, %URI{query: query}) when is_binary(query) do
|
||||||
|
case URI.decode_query(query)["fields"] do
|
||||||
|
nil -> params
|
||||||
|
value -> Map.put(params, "fields", value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp merge_fields_param_from_uri(params, %URI{}), do: params
|
||||||
|
|
||||||
|
defp merge_fields_param_from_uri(params, url) when is_binary(url) do
|
||||||
|
case URI.parse(url).query do
|
||||||
|
nil ->
|
||||||
|
params
|
||||||
|
|
||||||
|
q ->
|
||||||
|
case URI.decode_query(q)["fields"] do
|
||||||
|
nil -> params
|
||||||
|
value -> Map.put(params, "fields", value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp merge_fields_param_from_uri(params, _), do: params
|
||||||
|
|
||||||
defp build_base_params(query, sort_field, sort_order) do
|
defp build_base_params(query, sort_field, sort_order) do
|
||||||
%{
|
%{
|
||||||
"query" => query || "",
|
"query" => query || "",
|
||||||
|
|
@ -913,6 +966,15 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
query =
|
query =
|
||||||
Ash.Query.load(query, groups: [:id, :name, :slug])
|
Ash.Query.load(query, groups: [:id, :name, :slug])
|
||||||
|
|
||||||
|
# Load membership_fee_type when the column is visible or when sorting by it
|
||||||
|
query =
|
||||||
|
if :membership_fee_type in socket.assigns.member_fields_visible or
|
||||||
|
socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do
|
||||||
|
Ash.Query.load(query, membership_fee_type: [:id, :name])
|
||||||
|
else
|
||||||
|
query
|
||||||
|
end
|
||||||
|
|
||||||
query = apply_search_filter(query, search_query)
|
query = apply_search_filter(query, search_query)
|
||||||
|
|
||||||
query = apply_group_filters(query, socket.assigns[:group_filters], socket.assigns[:groups])
|
query = apply_group_filters(query, socket.assigns[:group_filters], socket.assigns[:groups])
|
||||||
|
|
@ -1073,6 +1135,10 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
field in [:groups, "groups"] ->
|
field in [:groups, "groups"] ->
|
||||||
{query, true}
|
{query, true}
|
||||||
|
|
||||||
|
# Membership fee type sort -> by related name at DB
|
||||||
|
field in [:membership_fee_type, "membership_fee_type"] ->
|
||||||
|
{Ash.Query.sort(query, [{"membership_fee_type.name", order}]), false}
|
||||||
|
|
||||||
# Custom field sort -> after load
|
# Custom field sort -> after load
|
||||||
custom_field_sort?(field) ->
|
custom_field_sort?(field) ->
|
||||||
{query, true}
|
{query, true}
|
||||||
|
|
@ -1118,11 +1184,16 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
defp valid_sort_field_db_or_custom?(field) when is_atom(field) do
|
defp valid_sort_field_db_or_custom?(field) when is_atom(field) do
|
||||||
non_sortable_fields = [:notes]
|
non_sortable_fields = [:notes]
|
||||||
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
|
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
|
||||||
field in valid_fields or custom_field_sort?(field) or field == :groups
|
field in valid_fields or custom_field_sort?(field) or field in [:groups, :membership_fee_type]
|
||||||
end
|
end
|
||||||
|
|
||||||
defp valid_sort_field_db_or_custom?(field) when is_binary(field) do
|
defp valid_sort_field_db_or_custom?(field) when is_binary(field) do
|
||||||
normalized = if field == "groups", do: :groups, else: safe_member_field_atom_only(field)
|
normalized =
|
||||||
|
cond do
|
||||||
|
field == "groups" -> :groups
|
||||||
|
field == "membership_fee_type" -> :membership_fee_type
|
||||||
|
true -> safe_member_field_atom_only(field)
|
||||||
|
end
|
||||||
|
|
||||||
(normalized != nil and valid_sort_field_db_or_custom?(normalized)) or
|
(normalized != nil and valid_sort_field_db_or_custom?(normalized)) or
|
||||||
custom_field_sort?(field)
|
custom_field_sort?(field)
|
||||||
|
|
@ -1647,13 +1718,11 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
FieldVisibility.computed_member_fields()
|
FieldVisibility.computed_member_fields()
|
||||||
|> Enum.filter(&(&1 in member_fields_computed))
|
|> Enum.filter(&(&1 in member_fields_computed))
|
||||||
|
|
||||||
# Include groups in export only if it's visible in the table
|
|
||||||
member_fields_with_groups =
|
member_fields_with_groups =
|
||||||
if :groups in socket.assigns[:member_fields_visible] do
|
build_export_member_fields_list(
|
||||||
ordered_member_fields_db ++ ["groups"]
|
ordered_member_fields_db,
|
||||||
else
|
socket.assigns[:member_fields_visible]
|
||||||
ordered_member_fields_db
|
)
|
||||||
end
|
|
||||||
|
|
||||||
# Order custom fields like the table (same as dynamic_cols / all_custom_fields order)
|
# Order custom fields like the table (same as dynamic_cols / all_custom_fields order)
|
||||||
ordered_custom_field_ids =
|
ordered_custom_field_ids =
|
||||||
|
|
@ -1674,7 +1743,9 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
export_column_order(
|
export_column_order(
|
||||||
ordered_member_fields_db,
|
ordered_member_fields_db,
|
||||||
ordered_computed_fields,
|
ordered_computed_fields,
|
||||||
ordered_custom_field_ids
|
ordered_custom_field_ids,
|
||||||
|
:membership_fee_type in socket.assigns[:member_fields_visible],
|
||||||
|
:groups in socket.assigns[:member_fields_visible]
|
||||||
),
|
),
|
||||||
query: socket.assigns[:query] || nil,
|
query: socket.assigns[:query] || nil,
|
||||||
sort_field: export_sort_field(socket.assigns[:sort_field]),
|
sort_field: export_sort_field(socket.assigns[:sort_field]),
|
||||||
|
|
@ -1685,6 +1756,41 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp expand_db_string_for_export(f, membership_fee_type_visible, computed_strings) do
|
||||||
|
if f == "membership_fee_start_date" do
|
||||||
|
extra =
|
||||||
|
if(membership_fee_type_visible, do: ["membership_fee_type"], else: []) ++
|
||||||
|
if "membership_fee_status" in computed_strings, do: ["membership_fee_status"], else: []
|
||||||
|
|
||||||
|
[f] ++ extra
|
||||||
|
else
|
||||||
|
[f]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_export_member_fields_list(ordered_db, member_fields_visible) do
|
||||||
|
with_extras =
|
||||||
|
Enum.flat_map(ordered_db, fn f ->
|
||||||
|
if f == :membership_fee_start_date and
|
||||||
|
:membership_fee_type in (member_fields_visible || []) do
|
||||||
|
[f, :membership_fee_type]
|
||||||
|
else
|
||||||
|
[f]
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
# If fee type is visible but start_date was not in the list, append it
|
||||||
|
with_extras =
|
||||||
|
if :membership_fee_type in (member_fields_visible || []) and
|
||||||
|
:membership_fee_type not in with_extras do
|
||||||
|
with_extras ++ [:membership_fee_type]
|
||||||
|
else
|
||||||
|
with_extras
|
||||||
|
end
|
||||||
|
|
||||||
|
if :groups in (member_fields_visible || []), do: with_extras ++ [:groups], else: with_extras
|
||||||
|
end
|
||||||
|
|
||||||
defp export_cycle_status_filter(nil), do: nil
|
defp export_cycle_status_filter(nil), do: nil
|
||||||
defp export_cycle_status_filter(:paid), do: "paid"
|
defp export_cycle_status_filter(:paid), do: "paid"
|
||||||
defp export_cycle_status_filter(:unpaid), do: "unpaid"
|
defp export_cycle_status_filter(:unpaid), do: "unpaid"
|
||||||
|
|
@ -1700,31 +1806,41 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
defp export_sort_order(o) when is_binary(o), do: o
|
defp export_sort_order(o) when is_binary(o), do: o
|
||||||
# Build a single ordered list that matches the table order:
|
# Build a single ordered list that matches the table order:
|
||||||
# - DB fields in Mv.Constants.member_fields() order (already pre-filtered as ordered_member_fields_db)
|
# - DB fields in Mv.Constants.member_fields() order (already pre-filtered as ordered_member_fields_db)
|
||||||
# - computed fields inserted at the correct position (membership_fee_status after membership_fee_start_date)
|
# - membership_fee_type and membership_fee_status inserted after membership_fee_start_date when visible
|
||||||
|
# - groups appended before custom fields when visible
|
||||||
# - custom fields appended in the same order as table (already ordered_custom_field_ids)
|
# - custom fields appended in the same order as table (already ordered_custom_field_ids)
|
||||||
defp export_column_order(
|
defp export_column_order(
|
||||||
ordered_member_fields_db,
|
ordered_member_fields_db,
|
||||||
ordered_computed_fields,
|
ordered_computed_fields,
|
||||||
ordered_custom_field_ids
|
ordered_custom_field_ids,
|
||||||
|
membership_fee_type_visible,
|
||||||
|
groups_visible
|
||||||
) do
|
) do
|
||||||
db_strings = Enum.map(ordered_member_fields_db, &Atom.to_string/1)
|
db_strings = Enum.map(ordered_member_fields_db, &Atom.to_string/1)
|
||||||
computed_strings = Enum.map(ordered_computed_fields, &Atom.to_string/1)
|
computed_strings = Enum.map(ordered_computed_fields, &Atom.to_string/1)
|
||||||
|
|
||||||
# Place membership_fee_status right after membership_fee_start_date if present in export
|
# Place membership_fee_type and membership_fee_status after membership_fee_start_date when present
|
||||||
db_with_computed =
|
db_with_extras =
|
||||||
Enum.flat_map(db_strings, fn f ->
|
Enum.flat_map(
|
||||||
if f == "membership_fee_start_date" and "membership_fee_status" in computed_strings do
|
db_strings,
|
||||||
[f, "membership_fee_status"]
|
&expand_db_string_for_export(&1, membership_fee_type_visible, computed_strings)
|
||||||
|
)
|
||||||
|
|
||||||
|
# If fee type is visible but start_date was not in the list, append it before computed/groups
|
||||||
|
db_with_extras =
|
||||||
|
if membership_fee_type_visible and "membership_fee_type" not in db_with_extras do
|
||||||
|
db_with_extras ++ ["membership_fee_type"]
|
||||||
else
|
else
|
||||||
[f]
|
db_with_extras
|
||||||
end
|
end
|
||||||
end)
|
|
||||||
|
|
||||||
# Any remaining computed fields not inserted above (future-proof)
|
# Any remaining computed fields not inserted above (future-proof)
|
||||||
remaining_computed =
|
remaining_computed =
|
||||||
computed_strings
|
computed_strings
|
||||||
|> Enum.reject(&(&1 in db_with_computed))
|
|> Enum.reject(&(&1 in db_with_extras))
|
||||||
|
|
||||||
db_with_computed ++ remaining_computed ++ ordered_custom_field_ids
|
result = db_with_extras ++ remaining_computed
|
||||||
|
result = if groups_visible, do: result ++ ["groups"], else: result
|
||||||
|
result ++ ordered_custom_field_ids
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -331,6 +331,28 @@
|
||||||
>
|
>
|
||||||
{MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)}
|
{MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)}
|
||||||
</:col>
|
</:col>
|
||||||
|
<:col
|
||||||
|
:let={member}
|
||||||
|
:if={:membership_fee_type in @member_fields_visible}
|
||||||
|
label={
|
||||||
|
~H"""
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
|
id={:sort_membership_fee_type}
|
||||||
|
field={:membership_fee_type}
|
||||||
|
label={gettext("Fee Type")}
|
||||||
|
sort_field={@sort_field}
|
||||||
|
sort_order={@sort_order}
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<%= if member.membership_fee_type do %>
|
||||||
|
{member.membership_fee_type.name}
|
||||||
|
<% else %>
|
||||||
|
<span class="text-base-content/50">—</span>
|
||||||
|
<% end %>
|
||||||
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:membership_fee_status in @member_fields_visible}
|
:if={:membership_fee_status in @member_fields_visible}
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,8 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
alias Mv.Membership.Helpers.VisibilityConfig
|
alias Mv.Membership.Helpers.VisibilityConfig
|
||||||
|
|
||||||
# Single UI key for "Membership Fee Status"; only this appears in the dropdown.
|
# Single UI key for "Membership Fee Status"; only this appears in the dropdown.
|
||||||
# Groups is also a pseudo field (not a DB attribute, but displayed in the table).
|
# Groups and membership_fee_type are also pseudo fields (not in member_fields(), displayed in the table).
|
||||||
@pseudo_member_fields [:membership_fee_status, :groups]
|
@pseudo_member_fields [:membership_fee_status, :membership_fee_type, :groups]
|
||||||
|
|
||||||
# Export/API may accept this as alias; must not appear in the UI options list.
|
# Export/API may accept this as alias; must not appear in the UI options list.
|
||||||
@export_only_alias :payment_status
|
@export_only_alias :payment_status
|
||||||
|
|
@ -64,6 +64,25 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
member_fields ++ custom_field_names
|
member_fields ++ custom_field_names
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Builds field selection from URL only: fields in `url_selection` are visible, all others false.
|
||||||
|
Use when `?fields=...` is in the URL so column visibility is not merged with global settings.
|
||||||
|
"""
|
||||||
|
@spec selection_from_url_only(%{String.t() => boolean()}, [struct()]) :: %{
|
||||||
|
String.t() => boolean()
|
||||||
|
}
|
||||||
|
def selection_from_url_only(url_selection, custom_fields) when is_map(url_selection) do
|
||||||
|
all_fields = get_all_available_fields(custom_fields)
|
||||||
|
|
||||||
|
Enum.reduce(all_fields, %{}, fn field, acc ->
|
||||||
|
field_string = field_to_string(field)
|
||||||
|
visible = Map.get(url_selection, field_string, false)
|
||||||
|
Map.put(acc, field_string, visible)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def selection_from_url_only(_, _), do: %{}
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Merges user field selection with global settings.
|
Merges user field selection with global settings.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ defmodule MvWeb.Translations.MemberFields do
|
||||||
def label(:country), do: gettext("Country")
|
def label(:country), do: gettext("Country")
|
||||||
def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date")
|
def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date")
|
||||||
def label(:membership_fee_status), do: gettext("Membership Fee Status")
|
def label(:membership_fee_status), do: gettext("Membership Fee Status")
|
||||||
|
def label(:membership_fee_type), do: gettext("Fee Type")
|
||||||
def label(:groups), do: gettext("Groups")
|
def label(:groups), do: gettext("Groups")
|
||||||
|
|
||||||
# Fallback for unknown fields
|
# Fallback for unknown fields
|
||||||
|
|
|
||||||
|
|
@ -2921,3 +2921,9 @@ msgstr "Sep."
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Required for Vereinfacht integration and cannot be disabled."
|
msgid "Required for Vereinfacht integration and cannot be disabled."
|
||||||
msgstr "Für die Vereinfacht-Integration erforderlich und kann nicht deaktiviert werden."
|
msgstr "Für die Vereinfacht-Integration erforderlich und kann nicht deaktiviert werden."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#: lib/mv_web/translations/member_fields.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Fee Type"
|
||||||
|
msgstr "Beitragsart"
|
||||||
|
|
|
||||||
|
|
@ -2921,3 +2921,9 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Required for Vereinfacht integration and cannot be disabled."
|
msgid "Required for Vereinfacht integration and cannot be disabled."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#: lib/mv_web/translations/member_fields.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Fee Type"
|
||||||
|
msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -2921,3 +2921,9 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Required for Vereinfacht integration and cannot be disabled."
|
msgid "Required for Vereinfacht integration and cannot be disabled."
|
||||||
msgstr "Required for Vereinfacht integration and cannot be disabled."
|
msgstr "Required for Vereinfacht integration and cannot be disabled."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#: lib/mv_web/translations/member_fields.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Fee Type"
|
||||||
|
msgstr "Fee Type"
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,43 @@ defmodule Mv.Membership.MembersCSVTest do
|
||||||
assert csv =~ "M,m@m.com,Paid"
|
assert csv =~ "M,m@m.com,Paid"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "membership_fee_type column exports fee type name" do
|
||||||
|
columns = [
|
||||||
|
%{header: "First Name", kind: :member_field, key: "first_name"},
|
||||||
|
%{header: "Email", kind: :member_field, key: "email"},
|
||||||
|
%{header: "Fee Type", kind: :membership_fee_type, key: :membership_fee_type}
|
||||||
|
]
|
||||||
|
|
||||||
|
member = %{
|
||||||
|
first_name: "M",
|
||||||
|
email: "m@m.com",
|
||||||
|
membership_fee_type: %{id: "ft-1", name: "Standard"}
|
||||||
|
}
|
||||||
|
|
||||||
|
iodata = MembersCSV.export([member], columns)
|
||||||
|
csv = IO.iodata_to_binary(iodata)
|
||||||
|
|
||||||
|
assert csv =~ "Fee Type"
|
||||||
|
assert csv =~ "Standard"
|
||||||
|
assert csv =~ "M,m@m.com,Standard"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "membership_fee_type column exports empty when no fee type" do
|
||||||
|
columns = [
|
||||||
|
%{header: "First Name", kind: :member_field, key: "first_name"},
|
||||||
|
%{header: "Fee Type", kind: :membership_fee_type, key: :membership_fee_type}
|
||||||
|
]
|
||||||
|
|
||||||
|
member = %{first_name: "M", email: "m@m.com", membership_fee_type: nil}
|
||||||
|
|
||||||
|
iodata = MembersCSV.export([member], columns)
|
||||||
|
csv = IO.iodata_to_binary(iodata)
|
||||||
|
|
||||||
|
assert csv =~ "Fee Type"
|
||||||
|
assert csv =~ "M,"
|
||||||
|
refute csv =~ "Standard"
|
||||||
|
end
|
||||||
|
|
||||||
test "CSV injection: formula-like and dangerous prefixes are escaped with apostrophe" do
|
test "CSV injection: formula-like and dangerous prefixes are escaped with apostrophe" do
|
||||||
member = %{
|
member = %{
|
||||||
first_name: "=SUM(A1:A10)",
|
first_name: "=SUM(A1:A10)",
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,70 @@ defmodule MvWeb.MemberExportControllerTest do
|
||||||
refute header =~ "unknown_field"
|
refute header =~ "unknown_field"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "export includes membership_fee_type column when requested", %{
|
||||||
|
conn: conn,
|
||||||
|
member1: m1
|
||||||
|
} do
|
||||||
|
payload = %{
|
||||||
|
"selected_ids" => [m1.id],
|
||||||
|
"member_fields" => ["first_name", "membership_fee_type", "email"],
|
||||||
|
"custom_field_ids" => [],
|
||||||
|
"query" => nil,
|
||||||
|
"sort_field" => nil,
|
||||||
|
"sort_order" => nil
|
||||||
|
}
|
||||||
|
|
||||||
|
conn = get(conn, "/members")
|
||||||
|
csrf_token = csrf_token_from_conn(conn)
|
||||||
|
|
||||||
|
conn =
|
||||||
|
post(conn, "/members/export.csv", %{
|
||||||
|
"payload" => Jason.encode!(payload),
|
||||||
|
"_csrf_token" => csrf_token
|
||||||
|
})
|
||||||
|
|
||||||
|
assert conn.status == 200
|
||||||
|
body = response(conn, 200)
|
||||||
|
header = body |> export_lines() |> hd()
|
||||||
|
|
||||||
|
# Fee Type column is included (label from MemberFields.label(:membership_fee_type))
|
||||||
|
assert header =~ "Fee Type"
|
||||||
|
assert body =~ "Alice"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Regression: when membership_fee_start_date is not in member_fields, Fee Type must still be exported (append fallback)
|
||||||
|
test "export includes Fee Type when only first_name and membership_fee_type are requested (no start_date)",
|
||||||
|
%{
|
||||||
|
conn: conn,
|
||||||
|
member1: m1
|
||||||
|
} do
|
||||||
|
payload = %{
|
||||||
|
"selected_ids" => [m1.id],
|
||||||
|
"member_fields" => ["first_name", "membership_fee_type"],
|
||||||
|
"custom_field_ids" => [],
|
||||||
|
"query" => nil,
|
||||||
|
"sort_field" => nil,
|
||||||
|
"sort_order" => nil
|
||||||
|
}
|
||||||
|
|
||||||
|
conn = get(conn, "/members")
|
||||||
|
csrf_token = csrf_token_from_conn(conn)
|
||||||
|
|
||||||
|
conn =
|
||||||
|
post(conn, "/members/export.csv", %{
|
||||||
|
"payload" => Jason.encode!(payload),
|
||||||
|
"_csrf_token" => csrf_token
|
||||||
|
})
|
||||||
|
|
||||||
|
assert conn.status == 200
|
||||||
|
body = response(conn, 200)
|
||||||
|
header = body |> export_lines() |> hd()
|
||||||
|
|
||||||
|
assert header =~ "Fee Type"
|
||||||
|
assert header =~ "First Name"
|
||||||
|
assert body =~ "Alice"
|
||||||
|
end
|
||||||
|
|
||||||
test "export includes membership_fee_status computed field when requested", %{
|
test "export includes membership_fee_status computed field when requested", %{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
member1: m1
|
member1: m1
|
||||||
|
|
@ -501,4 +565,40 @@ defmodule MvWeb.MemberExportControllerTest do
|
||||||
assert membership_idx < active_idx
|
assert membership_idx < active_idx
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "POST /members/export.pdf" do
|
||||||
|
test "PDF export includes Fee Type column when requested without membership_fee_start_date",
|
||||||
|
%{
|
||||||
|
conn: conn
|
||||||
|
} do
|
||||||
|
m =
|
||||||
|
Fixtures.member_fixture(%{first_name: "PDF", last_name: "Test", email: "pdf@example.com"})
|
||||||
|
|
||||||
|
payload = %{
|
||||||
|
"selected_ids" => [m.id],
|
||||||
|
"member_fields" => ["first_name", "membership_fee_type"],
|
||||||
|
"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.pdf", %{
|
||||||
|
"payload" => Jason.encode!(payload),
|
||||||
|
"_csrf_token" => csrf_token
|
||||||
|
})
|
||||||
|
|
||||||
|
assert conn.status == 200
|
||||||
|
assert get_resp_header(conn, "content-type") |> List.first() =~ "application/pdf"
|
||||||
|
|
||||||
|
body = response(conn, 200)
|
||||||
|
|
||||||
|
# PDF is generated successfully with Fee Type in columns (regression: fee type without start_date)
|
||||||
|
assert is_binary(body) and byte_size(body) > 0
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,14 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
|
||||||
assert field in result
|
assert field in result
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "includes pseudo member fields (membership_fee_status, membership_fee_type, groups)" do
|
||||||
|
result = FieldVisibility.get_all_available_fields([])
|
||||||
|
|
||||||
|
assert :membership_fee_status in result
|
||||||
|
assert :membership_fee_type in result
|
||||||
|
assert :groups in result
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "merge_with_global_settings/3" do
|
describe "merge_with_global_settings/3" do
|
||||||
|
|
@ -278,6 +286,20 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
|
||||||
test "handles invalid input" do
|
test "handles invalid input" do
|
||||||
assert FieldVisibility.get_visible_member_fields(nil) == []
|
assert FieldVisibility.get_visible_member_fields(nil) == []
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "returns membership_fee_type when visible in selection" do
|
||||||
|
selection = %{
|
||||||
|
"first_name" => true,
|
||||||
|
"membership_fee_type" => true,
|
||||||
|
"groups" => false
|
||||||
|
}
|
||||||
|
|
||||||
|
result = FieldVisibility.get_visible_member_fields(selection)
|
||||||
|
|
||||||
|
assert :membership_fee_type in result
|
||||||
|
assert :first_name in result
|
||||||
|
refute :groups in result
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "get_visible_custom_fields/1" do
|
describe "get_visible_custom_fields/1" do
|
||||||
|
|
|
||||||
|
|
@ -361,6 +361,18 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
assert html =~ "Alice"
|
assert html =~ "Alice"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "URL with only custom field keeps custom field visible (no invalid fallback)", %{
|
||||||
|
conn: conn,
|
||||||
|
custom_field: custom_field
|
||||||
|
} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
id = custom_field.id
|
||||||
|
{:ok, _view, html} = live(conn, "/members?fields=custom_field_#{id}")
|
||||||
|
|
||||||
|
# Selection must not be treated as invalid; custom field column stays visible
|
||||||
|
assert html =~ "M001" or html =~ custom_field.name
|
||||||
|
end
|
||||||
|
|
||||||
test "handles rapid toggling", %{conn: conn} do
|
test "handles rapid toggling", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue