fix: sorting and filter for export
This commit is contained in:
parent
e7d63b9b0a
commit
9b9e7ec995
10 changed files with 1013 additions and 714 deletions
344
lib/mv/membership/member_export.ex
Normal file
344
lib/mv/membership/member_export.ex
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
defmodule Mv.Membership.MemberExport do
|
||||
@moduledoc """
|
||||
Builds member list and column specs for CSV export.
|
||||
|
||||
Used by `MvWeb.MemberExportController`. Does not perform translations;
|
||||
the controller applies headers (e.g. via `MemberFields.label` / gettext)
|
||||
and sends the download.
|
||||
"""
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
alias Mv.Membership.CustomField
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.Membership.MemberExportSort
|
||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||
|
||||
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
||||
["membership_fee_status", "payment_status"]
|
||||
@computed_export_fields ["membership_fee_status", "payment_status"]
|
||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||
@domain_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
@doc """
|
||||
Fetches members and column specs for export.
|
||||
|
||||
- `actor` - Ash actor (e.g. current user)
|
||||
- `parsed` - Map from controller's parse_and_validate (selected_ids, member_fields, etc.)
|
||||
|
||||
Returns `{:ok, members, column_specs}` or `{:error, :forbidden}`.
|
||||
Column specs have `:kind`, `:key`, and for custom fields `:custom_field`;
|
||||
the controller adds `:header` and optional computed columns to members before CSV export.
|
||||
"""
|
||||
@spec fetch(struct(), map()) ::
|
||||
{:ok, [struct()], [map()]} | {:error, :forbidden}
|
||||
def fetch(actor, parsed) do
|
||||
custom_field_ids_union =
|
||||
(parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})) |> Enum.uniq()
|
||||
|
||||
with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(custom_field_ids_union, actor),
|
||||
{:ok, members} <- load_members(actor, parsed, custom_fields_by_id) do
|
||||
column_specs = build_column_specs(parsed, custom_fields_by_id)
|
||||
{:ok, members, column_specs}
|
||||
end
|
||||
end
|
||||
|
||||
defp load_custom_fields_by_id([], _actor), do: {:ok, %{}}
|
||||
|
||||
defp load_custom_fields_by_id(custom_field_ids, actor) do
|
||||
query =
|
||||
CustomField
|
||||
|> Ash.Query.filter(expr(id in ^custom_field_ids))
|
||||
|> Ash.Query.select([:id, :name, :value_type])
|
||||
|
||||
case Ash.read(query, actor: actor) do
|
||||
{:ok, custom_fields} ->
|
||||
by_id =
|
||||
Enum.reduce(custom_field_ids, %{}, fn id, acc ->
|
||||
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do
|
||||
nil -> acc
|
||||
cf -> Map.put(acc, id, cf)
|
||||
end
|
||||
end)
|
||||
|
||||
{:ok, by_id}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:error, :forbidden}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_column_specs(parsed, custom_fields_by_id) do
|
||||
member_specs =
|
||||
Enum.map(parsed.member_fields, fn f ->
|
||||
if f in parsed.selectable_member_fields do
|
||||
%{kind: :member_field, key: f}
|
||||
else
|
||||
%{kind: :computed, key: String.to_existing_atom(f)}
|
||||
end
|
||||
end)
|
||||
|
||||
custom_specs =
|
||||
parsed.custom_field_ids
|
||||
|> Enum.map(fn id -> Map.get(custom_fields_by_id, id) end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.map(fn cf -> %{kind: :custom_field, key: cf.id, custom_field: cf} end)
|
||||
|
||||
member_specs ++ custom_specs
|
||||
end
|
||||
|
||||
defp load_members(actor, parsed, custom_fields_by_id) do
|
||||
select_fields =
|
||||
[:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1)
|
||||
|
||||
custom_field_ids_union = parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})
|
||||
|
||||
need_cycles =
|
||||
parsed.show_current_cycle or parsed.cycle_status_filter != nil or
|
||||
parsed.computed_fields != []
|
||||
|
||||
query =
|
||||
Member
|
||||
|> Ash.Query.new()
|
||||
|> Ash.Query.select(select_fields)
|
||||
|> load_custom_field_values_query(custom_field_ids_union)
|
||||
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
||||
|
||||
query =
|
||||
if parsed.selected_ids != [] do
|
||||
Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
|
||||
else
|
||||
query
|
||||
|> apply_search(parsed.query)
|
||||
|> then(fn q ->
|
||||
{q, _sort_after_load} = maybe_sort(q, parsed.sort_field, parsed.sort_order)
|
||||
q
|
||||
end)
|
||||
end
|
||||
|
||||
case Ash.read(query, actor: actor) do
|
||||
{:ok, members} ->
|
||||
members =
|
||||
if parsed.selected_ids == [] do
|
||||
members
|
||||
|> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
|
||||
|> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
||||
parsed.boolean_filters || %{},
|
||||
Map.values(custom_fields_by_id)
|
||||
)
|
||||
else
|
||||
members
|
||||
end
|
||||
|
||||
members =
|
||||
if parsed.selected_ids == [] and sort_after_load?(parsed.sort_field) do
|
||||
sort_members_by_custom_field(
|
||||
members,
|
||||
parsed.sort_field,
|
||||
parsed.sort_order,
|
||||
Map.values(custom_fields_by_id)
|
||||
)
|
||||
else
|
||||
members
|
||||
end
|
||||
|
||||
{:ok, members}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:error, :forbidden}
|
||||
end
|
||||
end
|
||||
|
||||
defp load_custom_field_values_query(query, []), do: query
|
||||
|
||||
defp load_custom_field_values_query(query, custom_field_ids) do
|
||||
cfv_query =
|
||||
Mv.Membership.CustomFieldValue
|
||||
|> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids))
|
||||
|> Ash.Query.load(custom_field: [:id, :name, :value_type])
|
||||
|
||||
Ash.Query.load(query, custom_field_values: cfv_query)
|
||||
end
|
||||
|
||||
defp apply_search(query, nil), do: query
|
||||
defp apply_search(query, ""), do: query
|
||||
|
||||
defp apply_search(query, q) when is_binary(q) do
|
||||
if String.trim(q) != "" do
|
||||
Member.fuzzy_search(query, %{query: q})
|
||||
else
|
||||
query
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_sort(query, nil, _order), do: {query, false}
|
||||
defp maybe_sort(query, _field, nil), do: {query, false}
|
||||
|
||||
defp maybe_sort(query, field, order) when is_binary(field) do
|
||||
if custom_field_sort?(field) do
|
||||
{query, true}
|
||||
else
|
||||
field_atom = String.to_existing_atom(field)
|
||||
|
||||
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
|
||||
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
|
||||
else
|
||||
{query, false}
|
||||
end
|
||||
end
|
||||
rescue
|
||||
ArgumentError -> {query, false}
|
||||
end
|
||||
|
||||
defp sort_after_load?(field) when is_binary(field),
|
||||
do: String.starts_with?(field, @custom_field_prefix)
|
||||
|
||||
defp sort_after_load?(_), do: false
|
||||
|
||||
defp sort_members_by_custom_field(members, _field, _order, _custom_fields) when members == [],
|
||||
do: []
|
||||
|
||||
defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do
|
||||
id_str = String.trim_leading(field, @custom_field_prefix)
|
||||
custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
|
||||
if is_nil(custom_field), do: members
|
||||
|
||||
key_fn = fn member ->
|
||||
cfv = find_cfv(member, custom_field)
|
||||
raw = if cfv, do: cfv.value, else: nil
|
||||
MemberExportSort.custom_field_sort_key(custom_field.value_type, raw)
|
||||
end
|
||||
|
||||
members
|
||||
|> Enum.map(fn m -> {m, key_fn.(m)} end)
|
||||
|> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end)
|
||||
|> Enum.map(fn {m, _} -> m end)
|
||||
end
|
||||
|
||||
defp find_cfv(member, custom_field) do
|
||||
(member.custom_field_values || [])
|
||||
|> Enum.find(fn cfv ->
|
||||
to_string(cfv.custom_field_id) == to_string(custom_field.id) or
|
||||
(Map.get(cfv, :custom_field) &&
|
||||
to_string(cfv.custom_field.id) == to_string(custom_field.id))
|
||||
end)
|
||||
end
|
||||
|
||||
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
|
||||
|
||||
defp maybe_load_cycles(query, false, _show_current), do: query
|
||||
|
||||
defp maybe_load_cycles(query, true, show_current) do
|
||||
MembershipFeeStatus.load_cycles_for_members(query, show_current)
|
||||
end
|
||||
|
||||
defp apply_cycle_status_filter(members, nil, _show_current), do: members
|
||||
|
||||
defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do
|
||||
MembershipFeeStatus.filter_members_by_cycle_status(members, status, show_current)
|
||||
end
|
||||
|
||||
defp apply_cycle_status_filter(members, _status, _show_current), do: members
|
||||
|
||||
# Called by controller to build parsed map from raw params (kept here so controller stays thin)
|
||||
@doc """
|
||||
Parses and validates export params (from JSON payload).
|
||||
|
||||
Returns a map with :selected_ids, :member_fields, :selectable_member_fields,
|
||||
:computed_fields, :custom_field_ids, :query, :sort_field, :sort_order,
|
||||
:show_current_cycle, :cycle_status_filter, :boolean_filters.
|
||||
"""
|
||||
@spec parse_params(map()) :: map()
|
||||
def parse_params(params) do
|
||||
member_fields = filter_allowed_member_fields(extract_list(params, "member_fields"))
|
||||
{selectable_member_fields, computed_fields} = split_member_fields(member_fields)
|
||||
|
||||
%{
|
||||
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
|
||||
member_fields: member_fields,
|
||||
selectable_member_fields: selectable_member_fields,
|
||||
computed_fields: computed_fields,
|
||||
custom_field_ids: filter_valid_uuids(extract_list(params, "custom_field_ids")),
|
||||
query: extract_string(params, "query"),
|
||||
sort_field: extract_string(params, "sort_field"),
|
||||
sort_order: extract_sort_order(params),
|
||||
show_current_cycle: extract_boolean(params, "show_current_cycle"),
|
||||
cycle_status_filter: extract_cycle_status_filter(params),
|
||||
boolean_filters: extract_boolean_filters(params)
|
||||
}
|
||||
end
|
||||
|
||||
defp split_member_fields(member_fields) do
|
||||
selectable = Enum.filter(member_fields, fn f -> f in @domain_member_field_strings end)
|
||||
computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end)
|
||||
{selectable, computed}
|
||||
end
|
||||
|
||||
defp extract_boolean(params, key) do
|
||||
case Map.get(params, key) do
|
||||
true -> true
|
||||
"true" -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_cycle_status_filter(params) do
|
||||
case Map.get(params, "cycle_status_filter") do
|
||||
"paid" -> :paid
|
||||
"unpaid" -> :unpaid
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_boolean_filters(params) do
|
||||
case Map.get(params, "boolean_filters") do
|
||||
map when is_map(map) ->
|
||||
map
|
||||
|> Enum.filter(fn {k, v} -> is_binary(k) and is_boolean(v) end)
|
||||
|> Enum.filter(fn {k, _} -> match?({:ok, _}, Ecto.UUID.cast(k)) end)
|
||||
|> Enum.into(%{})
|
||||
|
||||
_ ->
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_list(params, key) do
|
||||
case Map.get(params, key) do
|
||||
list when is_list(list) -> list
|
||||
_ -> []
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_string(params, key) do
|
||||
case Map.get(params, key) do
|
||||
s when is_binary(s) -> s
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_sort_order(params) do
|
||||
case Map.get(params, "sort_order") do
|
||||
"asc" -> "asc"
|
||||
"desc" -> "desc"
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp filter_allowed_member_fields(field_list) do
|
||||
allowlist = MapSet.new(@member_fields_allowlist)
|
||||
|
||||
field_list
|
||||
|> Enum.filter(fn field -> is_binary(field) and MapSet.member?(allowlist, field) end)
|
||||
|> Enum.uniq()
|
||||
end
|
||||
|
||||
defp filter_valid_uuids(id_list) when is_list(id_list) do
|
||||
id_list
|
||||
|> Enum.filter(fn id ->
|
||||
is_binary(id) and match?({:ok, _}, Ecto.UUID.cast(id))
|
||||
end)
|
||||
|> Enum.uniq()
|
||||
end
|
||||
end
|
||||
44
lib/mv/membership/member_export_sort.ex
Normal file
44
lib/mv/membership/member_export_sort.ex
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
defmodule Mv.Membership.MemberExportSort do
|
||||
@moduledoc """
|
||||
Type-stable sort keys for CSV export custom-field sorting.
|
||||
|
||||
Used only by `MvWeb.MemberExportController` when sorting members by a custom field
|
||||
after load. Nil values sort last in ascending order and first in descending order.
|
||||
String and email comparison is case-insensitive.
|
||||
"""
|
||||
@doc """
|
||||
Returns a comparable sort key for (value_type, value).
|
||||
|
||||
- Nil: rank 1 so that in asc order nil sorts last, in desc nil sorts first.
|
||||
- date: chronological (ISO8601 string).
|
||||
- boolean: false < true (0 < 1).
|
||||
- integer: numerical order.
|
||||
- string / email: case-insensitive (downcased).
|
||||
|
||||
Handles Ash.Union in value; value_type is the custom field's value_type atom.
|
||||
"""
|
||||
@spec custom_field_sort_key(:string | :integer | :boolean | :date | :email, term()) ::
|
||||
{0 | 1, term()}
|
||||
def custom_field_sort_key(_value_type, nil), do: {1, nil}
|
||||
|
||||
def custom_field_sort_key(value_type, %Ash.Union{value: value, type: _type}) do
|
||||
custom_field_sort_key(value_type, value)
|
||||
end
|
||||
|
||||
def custom_field_sort_key(:date, %Date{} = d), do: {0, Date.to_iso8601(d)}
|
||||
def custom_field_sort_key(:boolean, true), do: {0, 1}
|
||||
def custom_field_sort_key(:boolean, false), do: {0, 0}
|
||||
def custom_field_sort_key(:integer, v) when is_integer(v), do: {0, v}
|
||||
def custom_field_sort_key(:string, v) when is_binary(v), do: {0, String.downcase(v)}
|
||||
def custom_field_sort_key(:email, v) when is_binary(v), do: {0, String.downcase(v)}
|
||||
def custom_field_sort_key(_value_type, v), do: {0, to_string(v)}
|
||||
|
||||
@doc """
|
||||
Returns true if key_a should sort before key_b for the given order.
|
||||
|
||||
"asc" -> nil last; "desc" -> nil first. No reverse of list needed.
|
||||
"""
|
||||
@spec key_lt({0 | 1, term()}, {0 | 1, term()}, String.t()) :: boolean()
|
||||
def key_lt(key_a, key_b, "asc"), do: key_a < key_b
|
||||
def key_lt(key_a, key_b, "desc"), do: key_b < key_a
|
||||
end
|
||||
|
|
@ -2,9 +2,11 @@ defmodule Mv.Membership.MembersCSV do
|
|||
@moduledoc """
|
||||
Exports members to CSV (RFC 4180) as iodata.
|
||||
|
||||
Uses NimbleCSV.RFC4180 for encoding. Member fields are formatted as strings;
|
||||
custom field values use the same formatting logic as the member overview (neutral formatter).
|
||||
Column order for custom fields follows the key order of the `custom_fields_by_id` map.
|
||||
Uses a column-based API: `export(members, columns)` where each column has
|
||||
`header` (display string, e.g. from Web layer), `kind` (:member_field | :custom_field | :computed),
|
||||
and `key` (member attribute name, custom_field id, or computed key). Custom field columns
|
||||
include a `custom_field` struct for value formatting. Domain code does not use Gettext;
|
||||
headers and computed values come from the caller (e.g. controller).
|
||||
"""
|
||||
alias Mv.Membership.CustomFieldValueFormatter
|
||||
alias NimbleCSV.RFC4180
|
||||
|
|
@ -12,57 +14,82 @@ defmodule Mv.Membership.MembersCSV do
|
|||
@doc """
|
||||
Exports a list of members to CSV iodata.
|
||||
|
||||
- `members` - List of member structs (with optional `custom_field_values` loaded)
|
||||
- `member_fields` - List of member field names (strings, e.g. `["first_name", "email"]`)
|
||||
- `custom_fields_by_id` - Map of custom_field_id => %CustomField{}. Key order defines column order.
|
||||
- `members` - List of member structs or maps (with optional `custom_field_values` loaded)
|
||||
- `columns` - List of column specs: `%{header: String.t(), kind: :member_field | :custom_field | :computed, key: term()}`
|
||||
For `:custom_field`, also pass `custom_field: %CustomField{}`. Header is used as-is (localized by caller).
|
||||
|
||||
Returns iodata suitable for `IO.iodata_to_binary/1` or sending as response body.
|
||||
RFC 4180 escaping and formula-injection safe_cell are applied.
|
||||
"""
|
||||
@spec export(
|
||||
[struct()],
|
||||
[String.t()],
|
||||
%{optional(String.t() | Ecto.UUID.t()) => struct()}
|
||||
) :: iodata()
|
||||
def export(members, member_fields, custom_fields_by_id) when is_list(members) do
|
||||
custom_entries = custom_field_entries(custom_fields_by_id)
|
||||
header = build_header(member_fields, custom_entries)
|
||||
rows = Enum.map(members, &build_row(&1, member_fields, custom_entries))
|
||||
@spec export([struct() | map()], [map()]) :: iodata()
|
||||
def export(members, columns) when is_list(members) do
|
||||
header = build_header(columns)
|
||||
rows = Enum.map(members, fn member -> build_row(member, columns) end)
|
||||
RFC4180.dump_to_iodata([header | rows])
|
||||
end
|
||||
|
||||
defp custom_field_entries(by_id) when is_map(by_id) do
|
||||
Enum.map(by_id, fn {id, cf} -> {to_string(id), cf} end)
|
||||
defp build_header(columns) do
|
||||
columns
|
||||
|> Enum.map(fn col -> col.header end)
|
||||
|> Enum.map(&safe_cell/1)
|
||||
end
|
||||
|
||||
defp build_header(member_fields, custom_entries) do
|
||||
member_headers = member_fields
|
||||
custom_headers = Enum.map(custom_entries, fn {_id, cf} -> cf.name end)
|
||||
member_headers ++ custom_headers
|
||||
defp build_row(member, columns) do
|
||||
columns
|
||||
|> Enum.map(fn col -> cell_value(member, col) end)
|
||||
|> Enum.map(&safe_cell/1)
|
||||
end
|
||||
|
||||
defp build_row(member, member_fields, custom_entries) do
|
||||
member_cells = Enum.map(member_fields, &format_member_field(member, &1))
|
||||
|
||||
custom_cells =
|
||||
Enum.map(custom_entries, fn {id, cf} -> format_custom_field(member, id, cf) end)
|
||||
|
||||
member_cells ++ custom_cells
|
||||
end
|
||||
|
||||
defp format_member_field(member, field_name) do
|
||||
key = member_field_key(field_name)
|
||||
value = Map.get(member, key)
|
||||
defp cell_value(member, %{kind: :member_field, key: key}) do
|
||||
key_atom = key_to_atom(key)
|
||||
value = Map.get(member, key_atom)
|
||||
format_member_value(value)
|
||||
end
|
||||
|
||||
defp member_field_key(field_name) when is_binary(field_name) do
|
||||
defp cell_value(member, %{kind: :custom_field, key: id, custom_field: cf}) do
|
||||
cfv = get_cfv_by_id(member, id)
|
||||
|
||||
if cfv,
|
||||
do: CustomFieldValueFormatter.format_custom_field_value(cfv.value, cf),
|
||||
else: ""
|
||||
end
|
||||
|
||||
defp cell_value(member, %{kind: :computed, key: key}) do
|
||||
value = Map.get(member, key_to_atom(key))
|
||||
if is_binary(value), do: value, else: ""
|
||||
end
|
||||
|
||||
defp key_to_atom(k) when is_atom(k), do: k
|
||||
|
||||
defp key_to_atom(k) when is_binary(k) do
|
||||
try do
|
||||
String.to_existing_atom(field_name)
|
||||
String.to_existing_atom(k)
|
||||
rescue
|
||||
ArgumentError -> field_name
|
||||
ArgumentError -> k
|
||||
end
|
||||
end
|
||||
|
||||
defp get_cfv_by_id(member, id) do
|
||||
values =
|
||||
case Map.get(member, :custom_field_values) do
|
||||
v when is_list(v) -> v
|
||||
_ -> []
|
||||
end
|
||||
|
||||
id_str = to_string(id)
|
||||
|
||||
Enum.find(values, fn cfv ->
|
||||
to_string(cfv.custom_field_id) == id_str or
|
||||
(Map.get(cfv, :custom_field) && to_string(cfv.custom_field.id) == id_str)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec safe_cell(String.t()) :: String.t()
|
||||
def safe_cell(s) when is_binary(s) do
|
||||
if String.starts_with?(s, ["=", "+", "-", "@", "\t"]), do: "'" <> s, else: s
|
||||
end
|
||||
|
||||
defp format_member_value(nil), do: ""
|
||||
defp format_member_value(true), do: "true"
|
||||
defp format_member_value(false), do: "false"
|
||||
|
|
@ -70,22 +97,4 @@ defmodule Mv.Membership.MembersCSV do
|
|||
defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
|
||||
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
|
||||
defp format_member_value(value), do: to_string(value)
|
||||
|
||||
defp format_custom_field(member, custom_field_id, custom_field) do
|
||||
cfv = find_custom_field_value(member, custom_field_id)
|
||||
|
||||
if cfv,
|
||||
do: CustomFieldValueFormatter.format_custom_field_value(cfv.value, custom_field),
|
||||
else: ""
|
||||
end
|
||||
|
||||
defp find_custom_field_value(member, custom_field_id) do
|
||||
values = Map.get(member, :custom_field_values) || []
|
||||
id_str = to_string(custom_field_id)
|
||||
|
||||
Enum.find(values, fn cfv ->
|
||||
to_string(cfv.custom_field_id) == id_str or
|
||||
(Map.get(cfv, :custom_field) && to_string(cfv.custom_field.id) == id_str)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -178,7 +178,8 @@ defmodule MvWeb.CoreComponents do
|
|||
aria-haspopup="menu"
|
||||
aria-expanded={@open}
|
||||
aria-controls={@id}
|
||||
class="btn"
|
||||
aria-label={@button_label}
|
||||
class="btn focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-base-content/20"
|
||||
phx-click="toggle_dropdown"
|
||||
phx-target={@phx_target}
|
||||
data-testid="dropdown-button"
|
||||
|
|
@ -232,11 +233,12 @@ defmodule MvWeb.CoreComponents do
|
|||
<button
|
||||
type="button"
|
||||
role={if @checkboxes, do: "menuitemcheckbox", else: "menuitem"}
|
||||
aria-label={item.label}
|
||||
aria-checked={
|
||||
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
|
||||
}
|
||||
tabindex="0"
|
||||
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left"
|
||||
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
|
||||
phx-click="select_item"
|
||||
phx-keydown="select_item"
|
||||
phx-key="Enter"
|
||||
|
|
@ -247,7 +249,7 @@ defmodule MvWeb.CoreComponents do
|
|||
<input
|
||||
type="checkbox"
|
||||
checked={Map.get(@selected, item.value, true)}
|
||||
class="checkbox checkbox-sm checkbox-primary"
|
||||
class="checkbox checkbox-sm checkbox-primary pointer-events-none"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ defmodule MvWeb.MemberExportController do
|
|||
%{
|
||||
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
|
||||
member_fields: filter_allowed_member_fields(extract_list(params, "member_fields")),
|
||||
computed_fields: filter_existing_atoms(extract_list(params, "computed_fields")),
|
||||
custom_field_ids: filter_valid_uuids(extract_list(params, "custom_field_ids")),
|
||||
query: extract_string(params, "query"),
|
||||
sort_field: extract_string(params, "sort_field"),
|
||||
|
|
@ -68,6 +69,20 @@ defmodule MvWeb.MemberExportController do
|
|||
}
|
||||
end
|
||||
|
||||
defp filter_existing_atoms(list) when is_list(list) do
|
||||
list
|
||||
|> Enum.filter(&is_binary/1)
|
||||
|> Enum.filter(fn name ->
|
||||
try do
|
||||
_ = String.to_existing_atom(name)
|
||||
true
|
||||
rescue
|
||||
ArgumentError -> false
|
||||
end
|
||||
end)
|
||||
|> Enum.uniq()
|
||||
end
|
||||
|
||||
defp extract_list(params, key) do
|
||||
case Map.get(params, key) do
|
||||
list when is_list(list) -> list
|
||||
|
|
@ -107,9 +122,16 @@ defmodule MvWeb.MemberExportController do
|
|||
end
|
||||
|
||||
defp run_export(conn, actor, parsed) do
|
||||
# FIX: Wenn nach einem Custom Field sortiert wird, muss dieses Feld geladen werden,
|
||||
# auch wenn es nicht exportiert wird (sonst kann Export nicht korrekt sortieren).
|
||||
parsed =
|
||||
parsed
|
||||
|> ensure_sort_custom_field_loaded()
|
||||
|
||||
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)
|
||||
columns = build_columns(conn, parsed, custom_fields_by_id)
|
||||
csv_iodata = MembersCSV.export(members, columns)
|
||||
filename = "members-#{Date.utc_today()}.csv"
|
||||
|
||||
send_download(
|
||||
|
|
@ -124,6 +146,26 @@ defmodule MvWeb.MemberExportController do
|
|||
end
|
||||
end
|
||||
|
||||
defp ensure_sort_custom_field_loaded(%{custom_field_ids: ids, sort_field: sort_field} = parsed) do
|
||||
case extract_sort_custom_field_id(sort_field) do
|
||||
nil ->
|
||||
parsed
|
||||
|
||||
id ->
|
||||
%{parsed | custom_field_ids: Enum.uniq([id | ids])}
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_sort_custom_field_id(field) when is_binary(field) do
|
||||
if String.starts_with?(field, @custom_field_prefix) do
|
||||
String.trim_leading(field, @custom_field_prefix)
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_sort_custom_field_id(_), do: nil
|
||||
|
||||
defp load_custom_fields_by_id([], _actor), do: {:ok, %{}}
|
||||
|
||||
defp load_custom_fields_by_id(custom_field_ids, actor) do
|
||||
|
|
@ -148,17 +190,13 @@ defmodule MvWeb.MemberExportController do
|
|||
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
|
||||
|
|
@ -170,20 +208,20 @@ defmodule MvWeb.MemberExportController do
|
|||
|
||||
query =
|
||||
if parsed.selected_ids != [] do
|
||||
# selected export: filtert die Menge, aber die Sortierung muss trotzdem wie in der Tabelle angewandt werden
|
||||
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
|
||||
|
||||
# FIX: Sortierung IMMER anwenden (auch bei selected_ids)
|
||||
{query, sort_after_load} = maybe_sort_export(query, parsed.sort_field, parsed.sort_order)
|
||||
|
||||
case Ash.read(query, actor: actor) do
|
||||
{:ok, members} ->
|
||||
members =
|
||||
if parsed.selected_ids == [] and sort_after_load?(parsed.sort_field) do
|
||||
if sort_after_load do
|
||||
sort_members_by_custom_field_export(
|
||||
members,
|
||||
parsed.sort_field,
|
||||
|
|
@ -191,7 +229,6 @@ defmodule MvWeb.MemberExportController do
|
|||
Map.values(custom_fields_by_id)
|
||||
)
|
||||
else
|
||||
# selected_ids != []: no sort. selected_ids == [] and DB sort: already in query.
|
||||
members
|
||||
end
|
||||
|
||||
|
|
@ -228,25 +265,29 @@ defmodule MvWeb.MemberExportController do
|
|||
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)
|
||||
cond do
|
||||
custom_field_sort?(field) ->
|
||||
# Custom field sort → in-memory nach dem Read (wie Tabelle)
|
||||
{query, true}
|
||||
|
||||
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
|
||||
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
|
||||
rescue
|
||||
ArgumentError -> {query, false}
|
||||
end
|
||||
|
||||
defp sort_after_load?(field) when is_binary(field),
|
||||
do: String.starts_with?(field, @custom_field_prefix)
|
||||
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
|
||||
|
||||
defp sort_after_load?(_), do: false
|
||||
# ------------------------------------------------------------------
|
||||
# Custom field sorting (match member table behavior)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
defp sort_members_by_custom_field_export(members, _field, _order, _custom_fields)
|
||||
when members == [],
|
||||
|
|
@ -254,26 +295,60 @@ defmodule MvWeb.MemberExportController do
|
|||
|
||||
defp sort_members_by_custom_field_export(members, field, order, custom_fields)
|
||||
when is_binary(field) do
|
||||
order = order || "asc"
|
||||
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
|
||||
custom_field =
|
||||
Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
|
||||
|
||||
sorted =
|
||||
if is_nil(custom_field) do
|
||||
members
|
||||
|> Enum.sort_by(extract_sort_val, fn
|
||||
nil, _ -> false
|
||||
_, nil -> true
|
||||
a, b -> if order == "desc", do: a >= b, else: a <= b
|
||||
end)
|
||||
else
|
||||
# Match table:
|
||||
# 1) values first, empty last
|
||||
# 2) sort only values
|
||||
# 3) for desc, reverse only the values-part
|
||||
{with_values, without_values} =
|
||||
Enum.split_with(members, fn member ->
|
||||
has_non_empty_custom_field_value?(member, custom_field)
|
||||
end)
|
||||
|
||||
if order == "desc", do: Enum.reverse(sorted), else: sorted
|
||||
sorted_with_values =
|
||||
Enum.sort_by(with_values, fn member ->
|
||||
member
|
||||
|> find_cfv(custom_field)
|
||||
|> case do
|
||||
nil -> nil
|
||||
cfv -> extract_sort_value(cfv.value, custom_field.value_type)
|
||||
end
|
||||
end)
|
||||
|
||||
sorted_with_values =
|
||||
if order == "desc", do: Enum.reverse(sorted_with_values), else: sorted_with_values
|
||||
|
||||
sorted_with_values ++ without_values
|
||||
end
|
||||
end
|
||||
|
||||
defp has_non_empty_custom_field_value?(member, custom_field) do
|
||||
case find_cfv(member, custom_field) do
|
||||
nil ->
|
||||
false
|
||||
|
||||
cfv ->
|
||||
extracted = extract_sort_value(cfv.value, custom_field.value_type)
|
||||
not empty_value?(extracted, custom_field.value_type)
|
||||
end
|
||||
end
|
||||
|
||||
defp empty_value?(nil, _type), do: true
|
||||
|
||||
defp empty_value?(value, type) when type in [:string, :email] and is_binary(value) do
|
||||
String.trim(value) == ""
|
||||
end
|
||||
|
||||
defp empty_value?(_value, _type), do: false
|
||||
|
||||
defp find_cfv(member, custom_field) do
|
||||
(member.custom_field_values || [])
|
||||
|> Enum.find(fn cfv ->
|
||||
|
|
@ -283,15 +358,76 @@ defmodule MvWeb.MemberExportController do
|
|||
end)
|
||||
end
|
||||
|
||||
defp build_columns(conn, parsed, custom_fields_by_id) do
|
||||
member_cols =
|
||||
Enum.map(parsed.member_fields, fn field ->
|
||||
%{
|
||||
header: member_field_header(conn, field),
|
||||
kind: :member_field,
|
||||
key: field
|
||||
}
|
||||
end)
|
||||
|
||||
computed_cols =
|
||||
Enum.map(parsed.computed_fields, fn key ->
|
||||
%{
|
||||
header: computed_field_header(conn, key),
|
||||
kind: :computed,
|
||||
key: key
|
||||
}
|
||||
end)
|
||||
|
||||
custom_cols =
|
||||
parsed.custom_field_ids
|
||||
|> Enum.map(fn id ->
|
||||
cf = Map.get(custom_fields_by_id, id) || Map.get(custom_fields_by_id, to_string(id))
|
||||
|
||||
if cf do
|
||||
%{
|
||||
header: custom_field_header(conn, cf),
|
||||
kind: :custom_field,
|
||||
key: to_string(id),
|
||||
custom_field: cf
|
||||
}
|
||||
else
|
||||
nil
|
||||
end
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
member_cols ++ computed_cols ++ custom_cols
|
||||
end
|
||||
|
||||
# --- headers: hier solltest du idealerweise eure bestehenden "display name" Helfer verwenden ---
|
||||
defp member_field_header(_conn, field) when is_binary(field) do
|
||||
# TODO: hier euren bestehenden display-name helper verwenden (wie Tabelle)
|
||||
humanize_field(field)
|
||||
end
|
||||
|
||||
defp computed_field_header(_conn, key) when is_binary(key) do
|
||||
# TODO: display-name helper für computed fields verwenden
|
||||
humanize_field(key)
|
||||
end
|
||||
|
||||
defp custom_field_header(_conn, cf) do
|
||||
# Custom fields: meist ist cf.name bereits der Display Name
|
||||
cf.name
|
||||
end
|
||||
|
||||
defp humanize_field(str) do
|
||||
str
|
||||
|> String.replace("_", " ")
|
||||
|> String.capitalize()
|
||||
end
|
||||
|
||||
defp extract_sort_value(%Ash.Union{value: value, type: type}, _),
|
||||
do: extract_sort_value(value, type)
|
||||
|
||||
defp extract_sort_value(nil, _), do: nil
|
||||
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
|
||||
|
|
|
|||
|
|
@ -41,24 +41,29 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
|
|||
# RENDER
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Export-only alias; must not appear in dropdown (canonical UI key is membership_fee_status).
|
||||
@payment_status_value "payment_status"
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
all_fields = assigns.all_fields || []
|
||||
custom_fields = assigns.custom_fields || []
|
||||
|
||||
all_items =
|
||||
Enum.map(extract_member_field_keys(all_fields), fn field ->
|
||||
%{
|
||||
value: field_to_string(field),
|
||||
label: format_field_label(field)
|
||||
}
|
||||
end) ++
|
||||
Enum.map(extract_custom_field_keys(all_fields), fn field ->
|
||||
%{
|
||||
value: field,
|
||||
label: format_custom_field_label(field, custom_fields)
|
||||
}
|
||||
end)
|
||||
(Enum.map(extract_member_field_keys(all_fields), fn field ->
|
||||
%{
|
||||
value: field_to_string(field),
|
||||
label: format_field_label(field)
|
||||
}
|
||||
end) ++
|
||||
Enum.map(extract_custom_field_keys(all_fields), fn field ->
|
||||
%{
|
||||
value: field,
|
||||
label: format_custom_field_label(field, custom_fields)
|
||||
}
|
||||
end))
|
||||
|> Enum.reject(fn item -> item.value == @payment_status_value end)
|
||||
|> Enum.uniq_by(fn item -> item.value end)
|
||||
|
||||
assigns = assign(assigns, :all_items, all_items)
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -293,6 +293,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:membership_fee_status in @member_fields_visible}
|
||||
label={gettext("Membership Fee Status")}
|
||||
>
|
||||
<%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(
|
||||
|
|
|
|||
|
|
@ -18,10 +18,25 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
1. User-specific selection (from URL/Session/Cookie)
|
||||
2. Global settings (from database)
|
||||
3. Default (all fields visible)
|
||||
|
||||
## Pseudo Member Fields
|
||||
|
||||
Overview-only fields that are not in `Mv.Constants.member_fields()` (e.g. computed/UI-only).
|
||||
They appear in the field dropdown and in `member_fields_visible` but are not domain attributes.
|
||||
"""
|
||||
|
||||
alias Mv.Membership.Helpers.VisibilityConfig
|
||||
|
||||
# Single UI key for "Membership Fee Status"; only this appears in the dropdown.
|
||||
@pseudo_member_fields [:membership_fee_status]
|
||||
|
||||
# Export/API may accept this as alias; must not appear in the UI options list.
|
||||
@export_only_alias :payment_status
|
||||
|
||||
defp overview_member_fields do
|
||||
Mv.Constants.member_fields() ++ @pseudo_member_fields
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all available fields for selection.
|
||||
|
||||
|
|
@ -39,7 +54,10 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
"""
|
||||
@spec get_all_available_fields([struct()]) :: [atom() | String.t()]
|
||||
def get_all_available_fields(custom_fields) do
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
member_fields =
|
||||
overview_member_fields()
|
||||
|> Enum.reject(fn field -> field == @export_only_alias end)
|
||||
|
||||
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
|
||||
|
||||
member_fields ++ custom_field_names
|
||||
|
|
@ -115,6 +133,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
field_selection
|
||||
|> Enum.filter(fn {_field, visible} -> visible end)
|
||||
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|
||||
|> Enum.uniq()
|
||||
end
|
||||
|
||||
def get_visible_fields(_), do: []
|
||||
|
|
@ -132,7 +151,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
"""
|
||||
@spec get_visible_member_fields(%{String.t() => boolean()}) :: [atom()]
|
||||
def get_visible_member_fields(field_selection) when is_map(field_selection) do
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
member_fields = overview_member_fields()
|
||||
|
||||
field_selection
|
||||
|> Enum.filter(fn {field_string, visible} ->
|
||||
|
|
@ -140,10 +159,61 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
visible && field_atom in member_fields
|
||||
end)
|
||||
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|
||||
|> Enum.uniq()
|
||||
end
|
||||
|
||||
def get_visible_member_fields(_), do: []
|
||||
|
||||
@doc """
|
||||
Returns the list of computed (UI-only) member field atoms.
|
||||
|
||||
These fields are not in the database; they must not be used for Ash query
|
||||
select/sort. Use this to filter sort options and validate sort_field.
|
||||
"""
|
||||
@spec computed_member_fields() :: [atom()]
|
||||
def computed_member_fields, do: @pseudo_member_fields
|
||||
|
||||
@doc """
|
||||
Visible member fields that are real DB attributes (from `Mv.Constants.member_fields()`).
|
||||
|
||||
Use for query select/sort. Not for rendering column visibility (use
|
||||
`get_visible_member_fields/1` for that).
|
||||
"""
|
||||
@spec get_visible_member_fields_db(%{String.t() => boolean()}) :: [atom()]
|
||||
def get_visible_member_fields_db(field_selection) when is_map(field_selection) do
|
||||
db_fields = MapSet.new(Mv.Constants.member_fields())
|
||||
|
||||
field_selection
|
||||
|> Enum.filter(fn {field_string, visible} ->
|
||||
field_atom = to_field_identifier(field_string)
|
||||
visible && field_atom in db_fields
|
||||
end)
|
||||
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|
||||
|> Enum.uniq()
|
||||
end
|
||||
|
||||
def get_visible_member_fields_db(_), do: []
|
||||
|
||||
@doc """
|
||||
Visible member fields that are computed/UI-only (e.g. membership_fee_status).
|
||||
|
||||
Use for rendering; do not use for query select or sort.
|
||||
"""
|
||||
@spec get_visible_member_fields_computed(%{String.t() => boolean()}) :: [atom()]
|
||||
def get_visible_member_fields_computed(field_selection) when is_map(field_selection) do
|
||||
computed_set = MapSet.new(@pseudo_member_fields)
|
||||
|
||||
field_selection
|
||||
|> Enum.filter(fn {field_string, visible} ->
|
||||
field_atom = to_field_identifier(field_string)
|
||||
visible && field_atom in computed_set
|
||||
end)
|
||||
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|
||||
|> Enum.uniq()
|
||||
end
|
||||
|
||||
def get_visible_member_fields_computed(_), do: []
|
||||
|
||||
@doc """
|
||||
Gets visible custom fields from field selection.
|
||||
|
||||
|
|
@ -176,19 +246,23 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
Map.merge(member_visibility, custom_field_visibility)
|
||||
end
|
||||
|
||||
# Gets member field visibility from settings
|
||||
# Gets member field visibility from settings (domain fields from settings, pseudo fields default true)
|
||||
defp get_member_field_visibility_from_settings(settings) do
|
||||
visibility_config =
|
||||
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
|
||||
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
domain_fields = Mv.Constants.member_fields()
|
||||
|
||||
Enum.reduce(member_fields, %{}, fn field, acc ->
|
||||
field_string = Atom.to_string(field)
|
||||
# exit_date defaults to false (hidden), all other fields default to true
|
||||
default_visibility = if field == :exit_date, do: false, else: true
|
||||
show_in_overview = Map.get(visibility_config, field, default_visibility)
|
||||
Map.put(acc, field_string, show_in_overview)
|
||||
domain_map =
|
||||
Enum.reduce(domain_fields, %{}, fn field, acc ->
|
||||
field_string = Atom.to_string(field)
|
||||
default_visibility = if field == :exit_date, do: false, else: true
|
||||
show_in_overview = Map.get(visibility_config, field, default_visibility)
|
||||
Map.put(acc, field_string, show_in_overview)
|
||||
end)
|
||||
|
||||
Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc ->
|
||||
Map.put(acc, Atom.to_string(field), true)
|
||||
end)
|
||||
end
|
||||
|
||||
|
|
@ -203,16 +277,20 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
end)
|
||||
end
|
||||
|
||||
# Converts field string to atom (for member fields) or keeps as string (for custom fields)
|
||||
# Converts field string to atom (for member fields) or keeps as string (for custom fields).
|
||||
# Maps export-only alias to canonical UI key so only one option controls the column.
|
||||
defp to_field_identifier(field_string) when is_binary(field_string) do
|
||||
if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do
|
||||
field_string
|
||||
else
|
||||
try do
|
||||
String.to_existing_atom(field_string)
|
||||
rescue
|
||||
ArgumentError -> field_string
|
||||
end
|
||||
atom =
|
||||
try do
|
||||
String.to_existing_atom(field_string)
|
||||
rescue
|
||||
ArgumentError -> field_string
|
||||
end
|
||||
|
||||
if atom == @export_only_alias, do: :membership_fee_status, else: atom
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ defmodule MvWeb.Translations.MemberFields do
|
|||
def label(:house_number), do: gettext("House Number")
|
||||
def label(:postal_code), do: gettext("Postal Code")
|
||||
def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date")
|
||||
def label(:membership_fee_status), do: gettext("Membership Fee Status")
|
||||
|
||||
# Fallback for unknown fields
|
||||
def label(field) do
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue