From e68a7cf8c7904e3fbb19d64f70b95798879854df Mon Sep 17 00:00:00 2001 From: carla Date: Mon, 9 Feb 2026 14:08:12 +0100 Subject: [PATCH] fix linting --- lib/mv/membership/member_export.ex | 179 +++--- .../controllers/member_export_controller.ex | 65 ++- lib/mv_web/live/member_live/index.ex | 64 ++- .../member_export_controller_test.exs | 538 +++++++++--------- 4 files changed, 456 insertions(+), 390 deletions(-) diff --git a/lib/mv/membership/member_export.ex b/lib/mv/membership/member_export.ex index e86eb96..e243d40 100644 --- a/lib/mv/membership/member_export.ex +++ b/lib/mv/membership/member_export.ex @@ -55,14 +55,7 @@ defmodule Mv.Membership.MemberExport do 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) - + by_id = build_custom_fields_by_id(custom_field_ids, custom_fields) {:ok, by_id} {:error, %Ash.Error.Forbidden{}} -> @@ -70,33 +63,72 @@ defmodule Mv.Membership.MemberExport do 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 - # only allow known computed export fields to avoid crashing on unknown atoms - if f in @computed_export_fields do - %{kind: :computed, key: String.to_existing_atom(f)} - else - # ignore unknown non-selectable fields defensively - nil - end - end - end) - |> Enum.reject(&is_nil/1) + defp build_custom_fields_by_id(custom_field_ids, custom_fields) do + Enum.reduce(custom_field_ids, %{}, fn id, acc -> + find_and_add_custom_field(acc, id, custom_fields) + end) + end - 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) + 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 build_column_specs(parsed, custom_fields_by_id) do + member_specs = build_member_column_specs(parsed) + custom_specs = build_custom_column_specs(parsed, custom_fields_by_id) member_specs ++ custom_specs end + defp build_member_column_specs(parsed) do + Enum.map(parsed.member_fields, fn f -> + build_single_member_spec(f, parsed.selectable_member_fields) + end) + |> Enum.reject(&is_nil/1) + end + + defp build_single_member_spec(field, selectable_member_fields) do + if field in selectable_member_fields do + %{kind: :member_field, key: field} + else + build_computed_spec(field) + end + end + + defp build_computed_spec(field) do + # only allow known computed export fields to avoid crashing on unknown atoms + if field in @computed_export_fields do + %{kind: :computed, key: String.to_existing_atom(field)} + else + # ignore unknown non-selectable fields defensively + nil + end + end + + defp build_custom_column_specs(parsed, custom_fields_by_id) do + 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) + end + defp load_members(actor, parsed, custom_fields_by_id) do + query = build_members_query(parsed, custom_fields_by_id) + + case Ash.read(query, actor: actor) do + {:ok, members} -> + processed_members = process_loaded_members(members, parsed, custom_fields_by_id) + {:ok, processed_members} + + {:error, %Ash.Error.Forbidden{}} -> + {:error, :forbidden} + end + end + + defp build_members_query(parsed, _custom_fields_by_id) do select_fields = [:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1) @@ -114,51 +146,48 @@ defmodule Mv.Membership.MemberExport do |> 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 + 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 + 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 + defp process_loaded_members(members, parsed, custom_fields_by_id) do + members + |> apply_post_load_filters(parsed, custom_fields_by_id) + |> apply_post_load_sorting(parsed, custom_fields_by_id) + |> add_computed_fields(parsed.computed_fields, parsed.show_current_cycle) + 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 + defp apply_post_load_filters(members, parsed, custom_fields_by_id) do + 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 + end - # Calculate membership_fee_status for computed fields - members = add_computed_fields(members, parsed.computed_fields, parsed.show_current_cycle) - - {:ok, members} - - {:error, %Ash.Error.Forbidden{}} -> - {:error, :forbidden} + defp apply_post_load_sorting(members, parsed, custom_fields_by_id) do + 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 end @@ -259,7 +288,8 @@ defmodule Mv.Membership.MemberExport do if "membership_fee_status" in computed_fields do Enum.map(members, fn member -> status = MembershipFeeStatus.get_cycle_status_for_member(member, show_current_cycle) - Map.put(member, :membership_fee_status, status) # <= Atom rein + # <= Atom rein + Map.put(member, :membership_fee_status, status) end) else members @@ -333,8 +363,9 @@ defmodule Mv.Membership.MemberExport 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.filter(fn {k, v} -> + is_binary(k) and is_boolean(v) and match?({:ok, _}, Ecto.UUID.cast(k)) + end) |> Enum.into(%{}) _ -> diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex index 6037e9d..009a985 100644 --- a/lib/mv_web/controllers/member_export_controller.ex +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -96,18 +96,21 @@ defmodule MvWeb.MemberExportController do 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 + is_binary(name) and atom_exists?(name) end) |> Enum.uniq() end + defp atom_exists?(name) do + try do + _ = String.to_existing_atom(name) + true + rescue + ArgumentError -> false + end + end + defp extract_list(params, key) do case Map.get(params, key) do list when is_list(list) -> list @@ -215,13 +218,17 @@ defmodule MvWeb.MemberExportController do defp build_custom_fields_by_id(custom_field_ids, custom_fields) do 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 + find_and_add_custom_field(acc, id, custom_fields) end) end + defp find_and_add_custom_field(acc, id, custom_fields) do + case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do + nil -> acc + cf -> Map.put(acc, id, cf) + end + end + defp load_members_for_export(actor, parsed, custom_fields_by_id) do select_fields = [:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1) @@ -322,19 +329,17 @@ 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 - cond do - custom_field_sort?(field) -> - # Custom field sort → in-memory nach dem Read (wie Tabelle) - {query, true} + if custom_field_sort?(field) do + # Custom field sort → in-memory nach dem Read (wie Tabelle) + {query, true} + else + field_atom = String.to_existing_atom(field) - 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 + 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} @@ -372,12 +377,7 @@ defmodule MvWeb.MemberExportController do 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 + extract_member_sort_value(member, custom_field) end) sorted_with_values = @@ -415,6 +415,13 @@ defmodule MvWeb.MemberExportController do end) end + defp extract_member_sort_value(member, custom_field) do + case find_cfv(member, custom_field) do + nil -> nil + cfv -> extract_sort_value(cfv.value, custom_field.value_type) + end + end + defp build_columns(conn, parsed, custom_fields_by_id) do member_cols = Enum.map(parsed.selectable_member_fields, fn field -> diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 640e133..881be53 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -245,7 +245,10 @@ defmodule MvWeb.MemberLive.Index do new_show_current, socket.assigns.boolean_custom_field_filters ) - |> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false) + |> maybe_add_field_selection( + socket.assigns[:user_field_selection], + socket.assigns[:fields_in_url?] || false + ) new_path = ~p"/members?#{query_params}" @@ -352,7 +355,10 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters ) - |> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false) + |> maybe_add_field_selection( + socket.assigns[:user_field_selection], + socket.assigns[:fields_in_url?] || false + ) {:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)} end @@ -374,7 +380,10 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters ) - |> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false) + |> maybe_add_field_selection( + socket.assigns[:user_field_selection], + socket.assigns[:fields_in_url?] || false + ) new_path = ~p"/members?#{query_params}" @@ -398,7 +407,10 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters ) - |> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false) + |> maybe_add_field_selection( + socket.assigns[:user_field_selection], + socket.assigns[:fields_in_url?] || false + ) new_path = ~p"/members?#{query_params}" {:noreply, push_patch(socket, to: new_path, replace: true)} @@ -428,7 +440,10 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.show_current_cycle, updated_filters ) - |> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false) + |> maybe_add_field_selection( + socket.assigns[:user_field_selection], + socket.assigns[:fields_in_url?] || false + ) new_path = ~p"/members?#{query_params}" {:noreply, push_patch(socket, to: new_path, replace: true)} @@ -452,7 +467,10 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.show_current_cycle, boolean_filters ) - |> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false) + |> maybe_add_field_selection( + socket.assigns[:user_field_selection], + socket.assigns[:fields_in_url?] || false + ) new_path = ~p"/members?#{query_params}" {:noreply, push_patch(socket, to: new_path, replace: true)} @@ -542,11 +560,12 @@ defmodule MvWeb.MemberLive.Index do @impl true def handle_params(params, _url, socket) do prev_sig = build_signature(socket) + fields_in_url? = - case Map.get(params, "fields") do - v when is_binary(v) and v != "" -> true - _ -> false - end + case Map.get(params, "fields") do + v when is_binary(v) and v != "" -> true + _ -> false + end url_selection = FieldSelection.parse_from_url(params) @@ -691,9 +710,10 @@ defmodule MvWeb.MemberLive.Index do defp maybe_add_field_selection(params, selection, true) when is_map(selection) do fields_param = FieldSelection.to_url_param(selection) - cond do - fields_param == "" -> Map.delete(params, "fields") - true -> Map.put(params, "fields", fields_param) + if fields_param == "" do + Map.delete(params, "fields") + else + Map.put(params, "fields", fields_param) end end @@ -900,15 +920,23 @@ defmodule MvWeb.MemberLive.Index do defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false} defp maybe_sort(query, field, order, _custom_fields) do + if computed_field?(field) do + {query, false} + else + apply_sort_to_query(query, field, order) + end + end + + defp computed_field?(field) do computed_atoms = FieldVisibility.computed_member_fields() computed_strings = Enum.map(computed_atoms, &Atom.to_string/1) - cond do - # Block computed fields (atom and string variants) - (is_atom(field) and field in computed_atoms) or - (is_binary(field) and field in computed_strings) -> - {query, false} + (is_atom(field) and field in computed_atoms) or + (is_binary(field) and field in computed_strings) + end + defp apply_sort_to_query(query, field, order) do + cond do # Custom field sort -> after load custom_field_sort?(field) -> {query, true} diff --git a/test/mv_web/controllers/member_export_controller_test.exs b/test/mv_web/controllers/member_export_controller_test.exs index 737288d..b7fff60 100644 --- a/test/mv_web/controllers/member_export_controller_test.exs +++ b/test/mv_web/controllers/member_export_controller_test.exs @@ -211,294 +211,294 @@ defmodule MvWeb.MemberExportControllerTest do assert header =~ "Membership Fee Status" end - setup %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() + setup %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() - # Create custom fields for different types - {:ok, string_field} = - Mv.Membership.CustomField - |> Ash.Changeset.for_create(:create, %{ - name: "Phone Number", - value_type: :string - }) - |> Ash.create(actor: system_actor) + # Create custom fields for different types + {:ok, string_field} = + Mv.Membership.CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Phone Number", + value_type: :string + }) + |> Ash.create(actor: system_actor) - {:ok, integer_field} = - Mv.Membership.CustomField - |> Ash.Changeset.for_create(:create, %{ - name: "Membership Number", - value_type: :integer - }) - |> Ash.create(actor: system_actor) + {:ok, integer_field} = + Mv.Membership.CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Membership Number", + value_type: :integer + }) + |> Ash.create(actor: system_actor) - {:ok, boolean_field} = - Mv.Membership.CustomField - |> Ash.Changeset.for_create(:create, %{ - name: "Active Member", - value_type: :boolean - }) - |> Ash.create(actor: system_actor) + {:ok, boolean_field} = + Mv.Membership.CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Active Member", + value_type: :boolean + }) + |> Ash.create(actor: system_actor) - # Create members with custom field values - {:ok, member_with_string} = - Mv.Membership.create_member( - %{ - first_name: "Test", - last_name: "String", - email: "test.string@example.com" - }, - actor: system_actor - ) + # Create members with custom field values + {:ok, member_with_string} = + Mv.Membership.create_member( + %{ + first_name: "Test", + last_name: "String", + email: "test.string@example.com" + }, + actor: system_actor + ) - {:ok, _cfv_string} = - Mv.Membership.CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member_with_string.id, - custom_field_id: string_field.id, - value: "+49 123 456789" - }) - |> Ash.create(actor: system_actor) + {:ok, _cfv_string} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_string.id, + custom_field_id: string_field.id, + value: "+49 123 456789" + }) + |> Ash.create(actor: system_actor) - {:ok, member_with_integer} = - Mv.Membership.create_member( - %{ - first_name: "Test", - last_name: "Integer", - email: "test.integer@example.com" - }, - actor: system_actor - ) + {:ok, member_with_integer} = + Mv.Membership.create_member( + %{ + first_name: "Test", + last_name: "Integer", + email: "test.integer@example.com" + }, + actor: system_actor + ) - {:ok, _cfv_integer} = - Mv.Membership.CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member_with_integer.id, - custom_field_id: integer_field.id, - value: 12345 - }) - |> Ash.create(actor: system_actor) + {:ok, _cfv_integer} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_integer.id, + custom_field_id: integer_field.id, + value: 12_345 + }) + |> Ash.create(actor: system_actor) - {:ok, member_with_boolean} = - Mv.Membership.create_member( - %{ - first_name: "Test", - last_name: "Boolean", - email: "test.boolean@example.com" - }, - actor: system_actor - ) + {:ok, member_with_boolean} = + Mv.Membership.create_member( + %{ + first_name: "Test", + last_name: "Boolean", + email: "test.boolean@example.com" + }, + actor: system_actor + ) - {:ok, _cfv_boolean} = - Mv.Membership.CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member_with_boolean.id, - custom_field_id: boolean_field.id, - value: true - }) - |> Ash.create(actor: system_actor) + {:ok, _cfv_boolean} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_boolean.id, + custom_field_id: boolean_field.id, + value: true + }) + |> Ash.create(actor: system_actor) - {:ok, member_without_value} = - Mv.Membership.create_member( - %{ - first_name: "Test", - last_name: "NoValue", - email: "test.novalue@example.com" - }, - actor: system_actor - ) + {:ok, member_without_value} = + Mv.Membership.create_member( + %{ + first_name: "Test", + last_name: "NoValue", + email: "test.novalue@example.com" + }, + actor: system_actor + ) - %{ - conn: conn, - string_field: string_field, - integer_field: integer_field, - boolean_field: boolean_field, - member_with_string: member_with_string, - member_with_integer: member_with_integer, - member_with_boolean: member_with_boolean, - member_without_value: member_without_value - } - end - - test "export includes custom field column with string value", %{ - conn: conn, - string_field: string_field, - member_with_string: member - } do - payload = %{ - "selected_ids" => [member.id], - "member_fields" => ["first_name", "last_name"], - "custom_field_ids" => [string_field.id], - "query" => nil, - "sort_field" => nil, - "sort_order" => nil - } - - conn = get(conn, "/members") - csrf_token = csrf_token_from_conn(conn) - - conn = - post(conn, "/members/export.csv", %{ - "payload" => Jason.encode!(payload), - "_csrf_token" => csrf_token - }) - - assert conn.status == 200 - body = response(conn, 200) - lines = export_lines(body) - header = hd(lines) - - assert header =~ "First Name" - assert header =~ "Last Name" - assert header =~ "Phone Number" - assert body =~ "Test" - assert body =~ "String" - assert body =~ "+49 123 456789" - end - - test "export includes custom field column with integer value", %{ - conn: conn, - integer_field: integer_field, - member_with_integer: member - } do - payload = %{ - "selected_ids" => [member.id], - "member_fields" => ["first_name"], - "custom_field_ids" => [integer_field.id], - "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 =~ "First Name" - assert header =~ "Membership Number" - assert body =~ "Test" - assert body =~ "12345" - end - - test "export includes custom field column with boolean value", %{ - conn: conn, - boolean_field: boolean_field, - member_with_boolean: member - } do - payload = %{ - "selected_ids" => [member.id], - "member_fields" => ["first_name"], - "custom_field_ids" => [boolean_field.id], - "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 =~ "First Name" - assert header =~ "Active Member" - assert body =~ "Test" - # Boolean values are formatted as "Yes" or "No" by CustomFieldValueFormatter - assert body =~ "Yes" - end - - test "export shows empty cell for member without custom field value", %{ - conn: conn, - string_field: string_field, - member_without_value: member - } do - payload = %{ - "selected_ids" => [member.id], - "member_fields" => ["first_name", "last_name"], - "custom_field_ids" => [string_field.id], - "query" => nil, - "sort_field" => nil, - "sort_order" => nil - } - - conn = get(conn, "/members") - csrf_token = csrf_token_from_conn(conn) - - conn = - post(conn, "/members/export.csv", %{ - "payload" => Jason.encode!(payload), - "_csrf_token" => csrf_token - }) - - assert conn.status == 200 - body = response(conn, 200) - lines = export_lines(body) - header = hd(lines) - data_line = Enum.at(lines, 1) - - assert header =~ "Phone Number" - # Empty custom field value should result in empty cell (two consecutive commas) - assert data_line =~ "Test,NoValue," - end - - test "export includes multiple custom fields in correct order", %{ + %{ conn: conn, string_field: string_field, integer_field: integer_field, boolean_field: boolean_field, - member_with_string: member - } do - payload = %{ - "selected_ids" => [member.id], - "member_fields" => ["first_name"], - "custom_field_ids" => [string_field.id, integer_field.id, boolean_field.id], - "query" => nil, - "sort_field" => nil, - "sort_order" => nil - } + member_with_string: member_with_string, + member_with_integer: member_with_integer, + member_with_boolean: member_with_boolean, + member_without_value: member_without_value + } + end - conn = get(conn, "/members") - csrf_token = csrf_token_from_conn(conn) + test "export includes custom field column with string value", %{ + conn: conn, + string_field: string_field, + member_with_string: member + } do + payload = %{ + "selected_ids" => [member.id], + "member_fields" => ["first_name", "last_name"], + "custom_field_ids" => [string_field.id], + "query" => nil, + "sort_field" => nil, + "sort_order" => nil + } - conn = - post(conn, "/members/export.csv", %{ - "payload" => Jason.encode!(payload), - "_csrf_token" => csrf_token - }) + conn = get(conn, "/members") + csrf_token = csrf_token_from_conn(conn) - assert conn.status == 200 - body = response(conn, 200) - header = body |> export_lines() |> hd() + conn = + post(conn, "/members/export.csv", %{ + "payload" => Jason.encode!(payload), + "_csrf_token" => csrf_token + }) - assert header =~ "First Name" - assert header =~ "Phone Number" - assert header =~ "Membership Number" - assert header =~ "Active Member" - # Verify order: member fields first, then custom fields in the order specified - header_parts = String.split(header, ",") - first_name_idx = Enum.find_index(header_parts, &String.contains?(&1, "First Name")) - phone_idx = Enum.find_index(header_parts, &String.contains?(&1, "Phone Number")) - membership_idx = Enum.find_index(header_parts, &String.contains?(&1, "Membership Number")) - active_idx = Enum.find_index(header_parts, &String.contains?(&1, "Active Member")) + assert conn.status == 200 + body = response(conn, 200) + lines = export_lines(body) + header = hd(lines) - assert first_name_idx < phone_idx - assert phone_idx < membership_idx - assert membership_idx < active_idx - end + assert header =~ "First Name" + assert header =~ "Last Name" + assert header =~ "Phone Number" + assert body =~ "Test" + assert body =~ "String" + assert body =~ "+49 123 456789" + end + + test "export includes custom field column with integer value", %{ + conn: conn, + integer_field: integer_field, + member_with_integer: member + } do + payload = %{ + "selected_ids" => [member.id], + "member_fields" => ["first_name"], + "custom_field_ids" => [integer_field.id], + "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 =~ "First Name" + assert header =~ "Membership Number" + assert body =~ "Test" + assert body =~ "12345" + end + + test "export includes custom field column with boolean value", %{ + conn: conn, + boolean_field: boolean_field, + member_with_boolean: member + } do + payload = %{ + "selected_ids" => [member.id], + "member_fields" => ["first_name"], + "custom_field_ids" => [boolean_field.id], + "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 =~ "First Name" + assert header =~ "Active Member" + assert body =~ "Test" + # Boolean values are formatted as "Yes" or "No" by CustomFieldValueFormatter + assert body =~ "Yes" + end + + test "export shows empty cell for member without custom field value", %{ + conn: conn, + string_field: string_field, + member_without_value: member + } do + payload = %{ + "selected_ids" => [member.id], + "member_fields" => ["first_name", "last_name"], + "custom_field_ids" => [string_field.id], + "query" => nil, + "sort_field" => nil, + "sort_order" => nil + } + + conn = get(conn, "/members") + csrf_token = csrf_token_from_conn(conn) + + conn = + post(conn, "/members/export.csv", %{ + "payload" => Jason.encode!(payload), + "_csrf_token" => csrf_token + }) + + assert conn.status == 200 + body = response(conn, 200) + lines = export_lines(body) + header = hd(lines) + data_line = Enum.at(lines, 1) + + assert header =~ "Phone Number" + # Empty custom field value should result in empty cell (two consecutive commas) + assert data_line =~ "Test,NoValue," + end + + test "export includes multiple custom fields in correct order", %{ + conn: conn, + string_field: string_field, + integer_field: integer_field, + boolean_field: boolean_field, + member_with_string: member + } do + payload = %{ + "selected_ids" => [member.id], + "member_fields" => ["first_name"], + "custom_field_ids" => [string_field.id, integer_field.id, boolean_field.id], + "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 =~ "First Name" + assert header =~ "Phone Number" + assert header =~ "Membership Number" + assert header =~ "Active Member" + # Verify order: member fields first, then custom fields in the order specified + header_parts = String.split(header, ",") + first_name_idx = Enum.find_index(header_parts, &String.contains?(&1, "First Name")) + phone_idx = Enum.find_index(header_parts, &String.contains?(&1, "Phone Number")) + membership_idx = Enum.find_index(header_parts, &String.contains?(&1, "Membership Number")) + active_idx = Enum.find_index(header_parts, &String.contains?(&1, "Active Member")) + + assert first_name_idx < phone_idx + assert phone_idx < membership_idx + assert membership_idx < active_idx + end end end