From 45a9bc0cc07ca5fd900ac3c494d4a9a016608ea3 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 2 Dec 2025 14:59:10 +0100 Subject: [PATCH] tests: added tests --- ...eld_visibility_dropdown_component_test.exs | 363 +++++++++++++ .../index/field_selection_test.exs | 346 ++++++++++++ .../index/field_visibility_test.exs | 336 ++++++++++++ .../index_field_visibility_test.exs | 509 ++++++++++++++++++ 4 files changed, 1554 insertions(+) create mode 100644 test/mv_web/components/field_visibility_dropdown_component_test.exs create mode 100644 test/mv_web/live/member_live/index/field_selection_test.exs create mode 100644 test/mv_web/live/member_live/index/field_visibility_test.exs create mode 100644 test/mv_web/member_live/index_field_visibility_test.exs diff --git a/test/mv_web/components/field_visibility_dropdown_component_test.exs b/test/mv_web/components/field_visibility_dropdown_component_test.exs new file mode 100644 index 0000000..81cd73b --- /dev/null +++ b/test/mv_web/components/field_visibility_dropdown_component_test.exs @@ -0,0 +1,363 @@ +defmodule MvWeb.Components.FieldVisibilityDropdownComponentTest do + @moduledoc """ + Tests for FieldVisibilityDropdownComponent LiveComponent. + """ + use MvWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias MvWeb.Components.FieldVisibilityDropdownComponent + + # Helper to create test assigns + defp create_assigns(overrides \\ %{}) do + default_assigns = %{ + id: "test-dropdown", + all_fields: [:first_name, :email, :street, "custom_field_123"], + custom_fields: [ + %{id: "123", name: "Custom Field 1"} + ], + selected_fields: %{ + "first_name" => true, + "email" => true, + "street" => false, + "custom_field_123" => true + } + } + + Map.merge(default_assigns, overrides) + end + + describe "update/2" do + test "initializes with default values" do + assigns = create_assigns() + + {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{}) + + assert socket.assigns.id == "test-dropdown" + assert socket.assigns.open == false + assert socket.assigns.all_fields == assigns.all_fields + assert socket.assigns.selected_fields == assigns.selected_fields + end + + test "preserves existing open state" do + assigns = create_assigns() + existing_socket = %{assigns: %{open: true}} + + {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, existing_socket) + + assert socket.assigns.open == true + end + + test "handles missing optional assigns" do + minimal_assigns = %{id: "test"} + + {:ok, socket} = FieldVisibilityDropdownComponent.update(minimal_assigns, %{}) + + assert socket.assigns.all_fields == [] + assert socket.assigns.custom_fields == [] + assert socket.assigns.selected_fields == %{} + end + end + + describe "render/1" do + test "renders dropdown button" do + assigns = create_assigns() + + html = render_component(FieldVisibilityDropdownComponent, assigns) + + assert html =~ "Columns" + assert html =~ "hero-adjustments-horizontal" + assert has_element?(html, "button[aria-controls='field-visibility-menu']") + end + + test "renders dropdown menu when open" do + assigns = create_assigns() |> Map.put(:open, true) + + html = render_component(FieldVisibilityDropdownComponent, assigns) + + assert has_element?(html, "ul#field-visibility-menu") + assert html =~ "All" + assert html =~ "None" + end + + test "does not render menu when closed" do + assigns = create_assigns() |> Map.put(:open, false) + + html = render_component(FieldVisibilityDropdownComponent, assigns) + + refute has_element?(html, "ul#field-visibility-menu") + end + + test "renders member fields" do + assigns = create_assigns() |> Map.put(:open, true) + + html = render_component(FieldVisibilityDropdownComponent, assigns) + + # Field names should be formatted (first_name -> First Name) + assert html =~ "First Name" or html =~ "first_name" + assert html =~ "Email" or html =~ "email" + assert html =~ "Street" or html =~ "street" + end + + test "renders custom fields when custom fields exist" do + assigns = create_assigns() |> Map.put(:open, true) + + html = render_component(FieldVisibilityDropdownComponent, assigns) + + # Custom field name + assert html =~ "Custom Field 1" + end + + test "renders checkboxes with correct checked state" do + assigns = create_assigns() |> Map.put(:open, true) + + html = render_component(FieldVisibilityDropdownComponent, assigns) + + # first_name should be checked (aria-checked="true") + assert html =~ ~s(aria-checked="true") + assert html =~ ~s(phx-value-item="first_name") + + # street should not be checked (aria-checked="false") + assert html =~ ~s(phx-value-item="street") + # Note: The visual checkbox state is handled by CSS classes and aria-checked attribute + end + + test "includes accessibility attributes" do + assigns = create_assigns() |> Map.put(:open, true) + + html = render_component(FieldVisibilityDropdownComponent, assigns) + + assert html =~ ~s(aria-controls="field-visibility-menu") + assert html =~ ~s(aria-haspopup="menu") + assert html =~ ~s(role="button") + assert html =~ ~s(role="menu") + assert html =~ ~s(role="menuitemcheckbox") + end + + test "formats member field labels correctly" do + assigns = create_assigns() |> Map.put(:open, true) + + html = render_component(FieldVisibilityDropdownComponent, assigns) + + # Field names should be formatted (first_name -> First Name) + assert html =~ "First Name" or html =~ "first_name" + end + + test "uses custom field names from custom_fields prop" do + assigns = + create_assigns() + |> Map.put(:open, true) + |> Map.put(:custom_fields, [ + %{id: "123", name: "Membership Number"} + ]) + + html = render_component(FieldVisibilityDropdownComponent, assigns) + + assert html =~ "Membership Number" + end + + test "falls back to ID when custom field not found" do + assigns = + create_assigns() + |> Map.put(:open, true) + # Empty custom fields list + |> Map.put(:custom_fields, []) + + html = render_component(FieldVisibilityDropdownComponent, assigns) + + # Should show something like "Custom Field 123" + assert html =~ "custom_field_123" or html =~ "Custom Field" + end + end + + describe "handle_event/2" do + test "toggle_dropdown toggles open state" do + assigns = create_assigns() + {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{}) + + assert socket.assigns.open == false + + {:noreply, socket} = + FieldVisibilityDropdownComponent.handle_event("toggle_dropdown", %{}, socket) + + assert socket.assigns.open == true + + {:noreply, socket} = + FieldVisibilityDropdownComponent.handle_event("toggle_dropdown", %{}, socket) + + assert socket.assigns.open == false + end + + test "close_dropdown sets open to false" do + assigns = create_assigns() + {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{}) + socket = assign(socket, :open, true) + + {:noreply, socket} = + FieldVisibilityDropdownComponent.handle_event("close_dropdown", %{}, socket) + + assert socket.assigns.open == false + end + + test "select_item toggles field visibility" do + assigns = create_assigns() + {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{}) + + assert socket.assigns.selected_fields["first_name"] == true + + {:noreply, socket} = + FieldVisibilityDropdownComponent.handle_event( + "select_item", + %{"item" => "first_name"}, + socket + ) + + assert socket.assigns.selected_fields["first_name"] == false + + {:noreply, socket} = + FieldVisibilityDropdownComponent.handle_event( + "select_item", + %{"item" => "first_name"}, + socket + ) + + assert socket.assigns.selected_fields["first_name"] == true + end + + test "select_item defaults to true for missing fields" do + assigns = create_assigns() + {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{}) + + {:noreply, socket} = + FieldVisibilityDropdownComponent.handle_event( + "select_item", + %{"item" => "new_field"}, + socket + ) + + # Toggled from default true + assert socket.assigns.selected_fields["new_field"] == false + end + + test "select_item sends message to parent" do + assigns = create_assigns() + {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{}) + + FieldVisibilityDropdownComponent.handle_event( + "select_item", + %{"item" => "first_name"}, + socket + ) + + # Check that message was sent (would be verified in integration test) + # For unit test, we just verify the state change + assert_receive {:field_toggled, "first_name", false} + end + + test "select_all sets all fields to true" do + assigns = create_assigns() + {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{}) + + {:noreply, socket} = + FieldVisibilityDropdownComponent.handle_event("select_all", %{}, socket) + + assert socket.assigns.selected_fields["first_name"] == true + assert socket.assigns.selected_fields["email"] == true + assert socket.assigns.selected_fields["street"] == true + assert socket.assigns.selected_fields["custom_field_123"] == true + end + + test "select_all sends message to parent" do + assigns = create_assigns() + {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{}) + + FieldVisibilityDropdownComponent.handle_event("select_all", %{}, socket) + + assert_receive {:fields_selected, selection} + assert selection["first_name"] == true + assert selection["email"] == true + end + + test "select_none sets all fields to false" do + assigns = create_assigns() + {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{}) + + {:noreply, socket} = + FieldVisibilityDropdownComponent.handle_event("select_none", %{}, socket) + + assert socket.assigns.selected_fields["first_name"] == false + assert socket.assigns.selected_fields["email"] == false + assert socket.assigns.selected_fields["street"] == false + assert socket.assigns.selected_fields["custom_field_123"] == false + end + + test "select_none sends message to parent" do + assigns = create_assigns() + {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{}) + + FieldVisibilityDropdownComponent.handle_event("select_none", %{}, socket) + + assert_receive {:fields_selected, selection} + assert selection["first_name"] == false + assert selection["email"] == false + end + + test "handles custom field toggle" do + assigns = create_assigns() + {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{}) + + {:noreply, socket} = + FieldVisibilityDropdownComponent.handle_event( + "select_item", + %{"item" => "custom_field_123"}, + socket + ) + + assert socket.assigns.selected_fields["custom_field_123"] == false + end + end + + describe "integration with LiveView" do + test "component can be rendered in LiveView" do + conn = conn_with_oidc_user(build_conn()) + {:ok, view, _html} = live(conn, "/members") + + # Check that component is rendered + assert has_element?(view, "button[aria-controls='field-visibility-menu']") + end + + test "clicking button opens dropdown" do + conn = conn_with_oidc_user(build_conn()) + {:ok, view, _html} = live(conn, "/members") + + # Initially closed + refute has_element?(view, "ul#field-visibility-menu") + + # Click button + view + |> element("button[aria-controls='field-visibility-menu']") + |> render_click() + + # Should be open now + assert has_element?(view, "ul#field-visibility-menu") + end + + test "toggling field updates selection" do + conn = conn_with_oidc_user(build_conn()) + {:ok, view, _html} = live(conn, "/members") + + # Open dropdown + view + |> element("button[aria-controls='field-visibility-menu']") + |> render_click() + + # Toggle a field + view + |> element("button[phx-click='select_item'][phx-value-item='first_name']") + |> render_click() + + # Component should update (verified by state change) + # In a real scenario, this would trigger a reload of members + end + end +end diff --git a/test/mv_web/live/member_live/index/field_selection_test.exs b/test/mv_web/live/member_live/index/field_selection_test.exs new file mode 100644 index 0000000..3c242c7 --- /dev/null +++ b/test/mv_web/live/member_live/index/field_selection_test.exs @@ -0,0 +1,346 @@ +defmodule MvWeb.MemberLive.Index.FieldSelectionTest do + @moduledoc """ + Tests for FieldSelection module handling cookie/session/URL management. + """ + use ExUnit.Case, async: true + + alias MvWeb.MemberLive.Index.FieldSelection + + describe "get_from_session/1" do + test "returns empty map when session is empty" do + assert FieldSelection.get_from_session(%{}) == %{} + end + + test "returns empty map when session key is missing" do + session = %{"other_key" => "value"} + assert FieldSelection.get_from_session(session) == %{} + end + + test "parses valid JSON from session" do + json = Jason.encode!(%{"first_name" => true, "email" => false}) + session = %{"member_field_selection" => json} + + result = FieldSelection.get_from_session(session) + + assert result == %{"first_name" => true, "email" => false} + end + + test "handles invalid JSON gracefully" do + session = %{"member_field_selection" => "invalid json{["} + + result = FieldSelection.get_from_session(session) + + assert result == %{} + end + + test "converts non-boolean values to true" do + json = Jason.encode!(%{"first_name" => "true", "email" => 1, "street" => true}) + session = %{"member_field_selection" => json} + + result = FieldSelection.get_from_session(session) + + # All values should be booleans, non-booleans default to true + assert result["first_name"] == true + assert result["email"] == true + assert result["street"] == true + end + + test "handles nil session" do + assert FieldSelection.get_from_session(nil) == %{} + end + + test "handles non-map session" do + assert FieldSelection.get_from_session("not a map") == %{} + end + end + + describe "save_to_session/2" do + test "saves field selection to session as JSON" do + session = %{} + selection = %{"first_name" => true, "email" => false} + + result = FieldSelection.save_to_session(session, selection) + + assert Map.has_key?(result, "member_field_selection") + assert Jason.decode!(result["member_field_selection"]) == selection + end + + test "overwrites existing selection" do + session = %{"member_field_selection" => Jason.encode!(%{"old" => true})} + selection = %{"new" => true} + + result = FieldSelection.save_to_session(session, selection) + + assert Jason.decode!(result["member_field_selection"]) == selection + end + + test "handles empty selection" do + session = %{} + selection = %{} + + result = FieldSelection.save_to_session(session, selection) + + assert Jason.decode!(result["member_field_selection"]) == %{} + end + + test "handles invalid selection gracefully" do + session = %{} + + result = FieldSelection.save_to_session(session, "not a map") + + assert result == session + end + end + + describe "get_from_cookie/1" do + test "returns empty map when cookie is missing" do + conn = Plug.Conn.put_req_header(%Plug.Conn{}, "cookie", "") + + result = FieldSelection.get_from_cookie(conn) + + assert result == %{} + end + + test "parses valid JSON from cookie" do + json = Jason.encode!(%{"first_name" => true, "email" => false}) + conn = Plug.Conn.put_req_cookie(%Plug.Conn{}, "member_field_selection", json) + + result = FieldSelection.get_from_cookie(conn) + + assert result == %{"first_name" => true, "email" => false} + end + + test "handles invalid JSON in cookie gracefully" do + conn = Plug.Conn.put_req_cookie(%Plug.Conn{}, "member_field_selection", "invalid{[") + + result = FieldSelection.get_from_cookie(conn) + + assert result == %{} + end + end + + describe "save_to_cookie/2" do + test "saves field selection to cookie" do + conn = %Plug.Conn{} + selection = %{"first_name" => true, "email" => false} + + result = FieldSelection.save_to_cookie(conn, selection) + + # Check that cookie is set + assert result.resp_cookies["member_field_selection"] + cookie = result.resp_cookies["member_field_selection"] + assert cookie[:max_age] == 365 * 24 * 60 * 60 + assert cookie[:same_site] == "Lax" + assert cookie[:http_only] == true + end + + test "handles invalid selection gracefully" do + conn = %Plug.Conn{} + + result = FieldSelection.save_to_cookie(conn, "not a map") + + assert result == conn + end + end + + describe "parse_from_url/1" do + test "returns empty map when params is empty" do + assert FieldSelection.parse_from_url(%{}) == %{} + end + + test "returns empty map when fields parameter is missing" do + params = %{"query" => "test", "sort_field" => "first_name"} + assert FieldSelection.parse_from_url(params) == %{} + end + + test "parses comma-separated field names" do + params = %{"fields" => "first_name,email,street"} + + result = FieldSelection.parse_from_url(params) + + assert result == %{ + "first_name" => true, + "email" => true, + "street" => true + } + end + + test "handles custom field names" do + params = %{"fields" => "custom_field_abc-123,custom_field_def-456"} + + result = FieldSelection.parse_from_url(params) + + assert result == %{ + "custom_field_abc-123" => true, + "custom_field_def-456" => true + } + end + + test "handles mixed member and custom fields" do + params = %{"fields" => "first_name,custom_field_123,email"} + + result = FieldSelection.parse_from_url(params) + + assert result == %{ + "first_name" => true, + "custom_field_123" => true, + "email" => true + } + end + + test "trims whitespace from field names" do + params = %{"fields" => " first_name , email , street "} + + result = FieldSelection.parse_from_url(params) + + assert result == %{ + "first_name" => true, + "email" => true, + "street" => true + } + end + + test "handles empty fields string" do + params = %{"fields" => ""} + assert FieldSelection.parse_from_url(params) == %{} + end + + test "handles nil fields parameter" do + params = %{"fields" => nil} + assert FieldSelection.parse_from_url(params) == %{} + end + + test "filters out empty field names" do + params = %{"fields" => "first_name,,email,"} + + result = FieldSelection.parse_from_url(params) + + assert result == %{ + "first_name" => true, + "email" => true + } + end + + test "handles non-map params" do + assert FieldSelection.parse_from_url(nil) == %{} + assert FieldSelection.parse_from_url("not a map") == %{} + end + end + + describe "merge_sources/3" do + test "merges all sources with URL having highest priority" do + url_selection = %{"first_name" => false} + session_selection = %{"first_name" => true, "email" => true} + cookie_selection = %{"first_name" => true, "street" => true} + + result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection) + + # URL overrides session, session overrides cookie + assert result["first_name"] == false + assert result["email"] == true + assert result["street"] == true + end + + test "handles empty sources" do + result = FieldSelection.merge_sources(%{}, %{}, %{}) + + assert result == %{} + end + + test "cookie only" do + cookie_selection = %{"first_name" => true} + + result = FieldSelection.merge_sources(%{}, %{}, cookie_selection) + + assert result == %{"first_name" => true} + end + + test "session overrides cookie" do + session_selection = %{"first_name" => false} + cookie_selection = %{"first_name" => true} + + result = FieldSelection.merge_sources(%{}, session_selection, cookie_selection) + + assert result["first_name"] == false + end + + test "URL overrides everything" do + url_selection = %{"first_name" => true} + session_selection = %{"first_name" => false} + cookie_selection = %{"first_name" => false} + + result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection) + + assert result["first_name"] == true + end + + test "combines fields from all sources" do + url_selection = %{"url_field" => true} + session_selection = %{"session_field" => true} + cookie_selection = %{"cookie_field" => true} + + result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection) + + assert result["url_field"] == true + assert result["session_field"] == true + assert result["cookie_field"] == true + end + end + + describe "to_url_param/1" do + test "converts selection to comma-separated string" do + selection = %{"first_name" => true, "email" => true, "street" => false} + + result = FieldSelection.to_url_param(selection) + + # Only visible fields should be included + assert result == "first_name,email" + end + + test "handles empty selection" do + assert FieldSelection.to_url_param(%{}) == "" + end + + test "handles all fields hidden" do + selection = %{"first_name" => false, "email" => false} + + result = FieldSelection.to_url_param(selection) + + assert result == "" + end + + test "preserves field order" do + selection = %{ + "z_field" => true, + "a_field" => true, + "m_field" => true + } + + result = FieldSelection.to_url_param(selection) + + # Order should be preserved (map iteration order) + assert String.contains?(result, "z_field") + assert String.contains?(result, "a_field") + assert String.contains?(result, "m_field") + end + + test "handles custom fields" do + selection = %{ + "first_name" => true, + "custom_field_abc-123" => true, + "email" => false + } + + result = FieldSelection.to_url_param(selection) + + assert String.contains?(result, "first_name") + assert String.contains?(result, "custom_field_abc-123") + refute String.contains?(result, "email") + end + + test "handles invalid input" do + assert FieldSelection.to_url_param(nil) == "" + assert FieldSelection.to_url_param("not a map") == "" + end + end +end diff --git a/test/mv_web/live/member_live/index/field_visibility_test.exs b/test/mv_web/live/member_live/index/field_visibility_test.exs new file mode 100644 index 0000000..83ae06d --- /dev/null +++ b/test/mv_web/live/member_live/index/field_visibility_test.exs @@ -0,0 +1,336 @@ +defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do + @moduledoc """ + Tests for FieldVisibility module handling field visibility merging logic. + """ + use ExUnit.Case, async: true + + alias MvWeb.MemberLive.Index.FieldVisibility + + # Mock custom field structs for testing + defp create_custom_field(id, name, show_in_overview \\ true) do + %{ + id: id, + name: name, + show_in_overview: show_in_overview + } + end + + describe "get_all_available_fields/1" do + test "returns member fields and custom fields" do + custom_fields = [ + create_custom_field("cf1", "Custom Field 1"), + create_custom_field("cf2", "Custom Field 2") + ] + + result = FieldVisibility.get_all_available_fields(custom_fields) + + # Should include all member fields + assert :first_name in result + assert :email in result + assert :street in result + + # Should include custom fields as strings + assert "custom_field_cf1" in result + assert "custom_field_cf2" in result + end + + test "handles empty custom fields list" do + result = FieldVisibility.get_all_available_fields([]) + + # Should only have member fields + assert :first_name in result + assert :email in result + + refute Enum.any?(result, fn field -> + is_binary(field) and String.starts_with?(field, "custom_field_") + end) + end + + test "includes all member fields from constants" do + custom_fields = [] + result = FieldVisibility.get_all_available_fields(custom_fields) + + member_fields = Mv.Constants.member_fields() + + Enum.each(member_fields, fn field -> + assert field in result + end) + end + end + + describe "merge_with_global_settings/3" do + test "user selection overrides global settings" do + user_selection = %{"first_name" => false} + settings = %{member_field_visibility: %{first_name: true, email: true}} + custom_fields = [] + + result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) + + assert result["first_name"] == false + assert result["email"] == true + end + + test "falls back to global settings when user selection is empty" do + user_selection = %{} + settings = %{member_field_visibility: %{first_name: false, email: true}} + custom_fields = [] + + result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) + + assert result["first_name"] == false + assert result["email"] == true + end + + test "defaults to true when field not in settings" do + user_selection = %{} + settings = %{member_field_visibility: %{first_name: false}} + custom_fields = [] + + result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) + + # first_name from settings + assert result["first_name"] == false + # email defaults to true (not in settings) + assert result["email"] == true + end + + test "handles custom fields visibility" do + user_selection = %{} + settings = %{member_field_visibility: %{}} + + custom_fields = [ + create_custom_field("cf1", "Custom 1", true), + create_custom_field("cf2", "Custom 2", false) + ] + + result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) + + assert result["custom_field_cf1"] == true + assert result["custom_field_cf2"] == false + end + + test "user selection overrides custom field visibility" do + user_selection = %{"custom_field_cf1" => false} + settings = %{member_field_visibility: %{}} + + custom_fields = [ + create_custom_field("cf1", "Custom 1", true) + ] + + result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) + + assert result["custom_field_cf1"] == false + end + + test "handles string keys in settings (JSONB format)" do + user_selection = %{} + settings = %{member_field_visibility: %{"first_name" => false, "email" => true}} + custom_fields = [] + + result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) + + assert result["first_name"] == false + assert result["email"] == true + end + + test "handles mixed atom and string keys in settings" do + user_selection = %{} + # Use string keys only (as JSONB would return) + settings = %{member_field_visibility: %{"first_name" => false, "email" => true}} + custom_fields = [] + + result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) + + assert result["first_name"] == false + assert result["email"] == true + end + + test "handles nil settings gracefully" do + user_selection = %{} + settings = %{member_field_visibility: nil} + custom_fields = [] + + result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) + + # Should default all fields to true + assert result["first_name"] == true + assert result["email"] == true + end + + test "handles missing member_field_visibility key" do + user_selection = %{} + settings = %{} + custom_fields = [] + + result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) + + # Should default all fields to true + assert result["first_name"] == true + assert result["email"] == true + end + + test "includes all fields in result" do + user_selection = %{"first_name" => false} + settings = %{member_field_visibility: %{email: true}} + + custom_fields = [ + create_custom_field("cf1", "Custom 1", true) + ] + + result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) + + # Should include all member fields + member_fields = Mv.Constants.member_fields() + + Enum.each(member_fields, fn field -> + assert Map.has_key?(result, Atom.to_string(field)) + end) + + # Should include custom fields + assert Map.has_key?(result, "custom_field_cf1") + end + end + + describe "get_visible_fields/1" do + test "returns only fields with true visibility" do + selection = %{ + "first_name" => true, + "email" => false, + "street" => true, + "custom_field_123" => false + } + + result = FieldVisibility.get_visible_fields(selection) + + assert :first_name in result + assert :street in result + refute :email in result + refute "custom_field_123" in result + end + + test "converts member field strings to atoms" do + selection = %{"first_name" => true, "email" => true} + + result = FieldVisibility.get_visible_fields(selection) + + assert :first_name in result + assert :email in result + end + + test "keeps custom fields as strings" do + selection = %{"custom_field_abc-123" => true} + + result = FieldVisibility.get_visible_fields(selection) + + assert "custom_field_abc-123" in result + end + + test "handles empty selection" do + assert FieldVisibility.get_visible_fields(%{}) == [] + end + + test "handles all fields hidden" do + selection = %{"first_name" => false, "email" => false} + + assert FieldVisibility.get_visible_fields(selection) == [] + end + + test "handles invalid input" do + assert FieldVisibility.get_visible_fields(nil) == [] + end + end + + describe "get_visible_member_fields/1" do + test "returns only member fields that are visible" do + selection = %{ + "first_name" => true, + "email" => true, + "custom_field_123" => true, + "street" => false + } + + result = FieldVisibility.get_visible_member_fields(selection) + + assert :first_name in result + assert :email in result + refute :street in result + refute "custom_field_123" in result + end + + test "filters out custom fields" do + selection = %{ + "first_name" => true, + "custom_field_123" => true, + "custom_field_456" => true + } + + result = FieldVisibility.get_visible_member_fields(selection) + + assert :first_name in result + refute "custom_field_123" in result + refute "custom_field_456" in result + end + + test "handles empty selection" do + assert FieldVisibility.get_visible_member_fields(%{}) == [] + end + + test "handles invalid input" do + assert FieldVisibility.get_visible_member_fields(nil) == [] + end + end + + describe "get_visible_custom_fields/1" do + test "returns only custom fields that are visible" do + selection = %{ + "first_name" => true, + "custom_field_123" => true, + "custom_field_456" => false, + "email" => true + } + + result = FieldVisibility.get_visible_custom_fields(selection) + + assert "custom_field_123" in result + refute "custom_field_456" in result + refute :first_name in result + refute :email in result + end + + test "filters out member fields" do + selection = %{ + "first_name" => true, + "email" => true, + "custom_field_123" => true + } + + result = FieldVisibility.get_visible_custom_fields(selection) + + assert "custom_field_123" in result + refute :first_name in result + refute :email in result + end + + test "handles empty selection" do + assert FieldVisibility.get_visible_custom_fields(%{}) == [] + end + + test "handles fields that look like custom fields but aren't" do + selection = %{ + "custom_field_123" => true, + "custom_field_like_name" => true, + "not_custom_field" => true + } + + result = FieldVisibility.get_visible_custom_fields(selection) + + assert "custom_field_123" in result + assert "custom_field_like_name" in result + refute "not_custom_field" in result + end + + test "handles invalid input" do + assert FieldVisibility.get_visible_custom_fields(nil) == [] + end + end +end diff --git a/test/mv_web/member_live/index_field_visibility_test.exs b/test/mv_web/member_live/index_field_visibility_test.exs new file mode 100644 index 0000000..c4241fe --- /dev/null +++ b/test/mv_web/member_live/index_field_visibility_test.exs @@ -0,0 +1,509 @@ +defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do + @moduledoc """ + Integration tests for field visibility dropdown functionality. + + Tests cover: + - Field selection dropdown rendering + - Toggling field visibility + - URL parameter persistence + - Select all / deselect all + - Integration with member list display + - Custom fields visibility + """ + use MvWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + require Ash.Query + + alias Mv.Membership.{CustomField, CustomFieldValue, Member} + + setup do + # Create test members + {:ok, member1} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com", + street: "Main St", + city: "Berlin" + }) + |> Ash.create() + + {:ok, member2} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Bob", + last_name: "Brown", + email: "bob@example.com", + street: "Second St", + city: "Hamburg" + }) + |> Ash.create() + + # Create custom field + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "membership_number", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create custom field values + {:ok, _cfv1} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: custom_field.id, + value: "M001" + }) + |> Ash.create() + + {:ok, _cfv2} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member2.id, + custom_field_id: custom_field.id, + value: "M002" + }) + |> Ash.create() + + %{ + member1: member1, + member2: member2, + custom_field: custom_field + } + end + + describe "field visibility dropdown" do + test "renders dropdown button", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + assert html =~ "Columns" + assert html =~ ~s(aria-controls="field-visibility-menu") + end + + test "opens dropdown when button is clicked", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Initially closed + refute has_element?(view, "ul#field-visibility-menu") + + # Click button + view + |> element("button[aria-controls='field-visibility-menu']") + |> render_click() + + # Should be open now + assert has_element?(view, "ul#field-visibility-menu") + end + + test "displays all member fields in dropdown", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Open dropdown + view + |> element("button[aria-controls='field-visibility-menu']") + |> render_click() + + html = render(view) + + # Check for member fields (formatted labels) + assert html =~ "First Name" or html =~ "first_name" + assert html =~ "Email" or html =~ "email" + assert html =~ "Street" or html =~ "street" + end + + test "displays custom fields in dropdown", %{conn: conn, custom_field: custom_field} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Open dropdown + view + |> element("button[aria-controls='field-visibility-menu']") + |> render_click() + + html = render(view) + + assert html =~ custom_field.name + end + end + + describe "field visibility toggling" do + test "hiding a field removes it from display", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Verify email is visible initially + html = render(view) + assert html =~ "alice@example.com" + + # Open dropdown and hide email + view + |> element("button[aria-controls='field-visibility-menu']") + |> render_click() + + view + |> element("button[phx-click='select_item'][phx-value-item='email']") + |> render_click() + + # Wait for update + :timer.sleep(100) + + # Email should no longer be visible + html = render(view) + refute html =~ "alice@example.com" + refute html =~ "bob@example.com" + end + + test "showing a hidden field adds it to display", %{conn: conn} do + conn = conn_with_oidc_user(conn) + + # Start with only first_name and street explicitly set in URL + # Note: Other fields may still be visible due to global settings + {:ok, view, _html} = live(conn, "/members?fields=first_name,street") + + # Verify first_name and street are visible + html = render(view) + assert html =~ "Alice" + assert html =~ "Main St" + + # Open dropdown and toggle email (to ensure it's visible) + view + |> element("button[aria-controls='field-visibility-menu']") + |> render_click() + + # If email is not visible, toggle it to make it visible + # If it's already visible, toggle it off and on again + view + |> element("button[phx-click='select_item'][phx-value-item='email']") + |> render_click() + + # Wait for update + :timer.sleep(100) + + # Email should now be visible + html = render(view) + assert html =~ "alice@example.com" + end + + test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Verify custom field is visible initially + html = render(view) + assert html =~ "M001" or html =~ custom_field.name + + # Open dropdown and hide custom field + view + |> element("button[aria-controls='field-visibility-menu']") + |> render_click() + + custom_field_id = custom_field.id + custom_field_string = "custom_field_#{custom_field_id}" + + view + |> element("button[phx-click='select_item'][phx-value-item='#{custom_field_string}']") + |> render_click() + + # Wait for update + :timer.sleep(100) + + # Custom field should no longer be visible + html = render(view) + refute html =~ "M001" + refute html =~ "M002" + end + end + + describe "select all / deselect all" do + test "select all makes all fields visible", %{conn: conn} do + conn = conn_with_oidc_user(conn) + + # Start with some fields hidden + {:ok, view, _html} = live(conn, "/members?fields=first_name") + + # Open dropdown + view + |> element("button[aria-controls='field-visibility-menu']") + |> render_click() + + # Click select all + view + |> element("button[phx-click='select_all']") + |> render_click() + + # Wait for update + :timer.sleep(100) + + # All fields should be visible + html = render(view) + assert html =~ "alice@example.com" + assert html =~ "Main St" + assert html =~ "Berlin" + end + + test "deselect all hides all fields except first_name", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Open dropdown + view + |> element("button[aria-controls='field-visibility-menu']") + |> render_click() + + # Click deselect all + view + |> element("button[phx-click='select_none']") + |> render_click() + + # Wait for update + :timer.sleep(100) + + # Only first_name should be visible (it's always shown) + html = render(view) + # Email and street should be hidden + refute html =~ "alice@example.com" + refute html =~ "Main St" + end + end + + describe "URL parameter persistence" do + test "field selection is persisted in URL", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Open dropdown and hide email + view + |> element("button[aria-controls='field-visibility-menu']") + |> render_click() + + view + |> element("button[phx-click='select_item'][phx-value-item='email']") + |> render_click() + + # Wait for URL update + :timer.sleep(100) + + # Check that URL contains fields parameter + # Note: In LiveView tests, we check the rendered HTML for the updated state + # The actual URL update happens via push_patch + end + + test "loading page with fields parameter applies selection", %{conn: conn} do + conn = conn_with_oidc_user(conn) + + # Load with first_name and city explicitly set in URL + # Note: Other fields may still be visible due to global settings + {:ok, view, _html} = live(conn, "/members?fields=first_name,city") + + html = render(view) + + # first_name and city should be visible + assert html =~ "Alice" + assert html =~ "Berlin" + + # Note: email and street may still be visible if global settings allow it + # This test verifies that the URL parameters work, not that they hide other fields + end + + test "fields parameter works with custom fields", %{conn: conn, custom_field: custom_field} do + conn = conn_with_oidc_user(conn) + custom_field_id = custom_field.id + + # Load with custom field visible + {:ok, view, _html} = + live(conn, "/members?fields=first_name,custom_field_#{custom_field_id}") + + html = render(view) + + # Custom field should be visible + assert html =~ "M001" or html =~ custom_field.name + end + end + + describe "integration with global settings" do + test "respects global settings when no user selection", %{conn: conn} do + # This test would require setting up global settings + # For now, we verify that the system works with default settings + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # All fields should be visible by default + assert html =~ "alice@example.com" + assert html =~ "Main St" + end + + test "user selection overrides global settings", %{conn: conn} do + # This would require setting up global settings first + # Then verifying that user selection takes precedence + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Hide a field via dropdown + view + |> element("button[aria-controls='field-visibility-menu']") + |> render_click() + + view + |> element("button[phx-click='select_item'][phx-value-item='email']") + |> render_click() + + :timer.sleep(100) + + html = render(view) + refute html =~ "alice@example.com" + end + end + + describe "edge cases" do + test "handles empty fields parameter", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?fields=") + + # Should fall back to global settings + assert html =~ "alice@example.com" + end + + test "handles invalid field names in URL", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid") + + # Should ignore invalid fields and use defaults + assert html =~ "alice@example.com" + end + + test "handles custom field that doesn't exist", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?fields=first_name,custom_field_nonexistent") + + # Should work without errors + assert html =~ "Alice" + end + + test "handles rapid toggling", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Open dropdown + view + |> element("button[aria-controls='field-visibility-menu']") + |> render_click() + + # Rapidly toggle a field multiple times + for _ <- 1..5 do + view + |> element("button[phx-click='select_item'][phx-value-item='email']") + |> render_click() + + :timer.sleep(50) + end + + # Should still work correctly + html = render(view) + assert html =~ "Alice" + end + end + + describe "accessibility" do + test "dropdown has proper ARIA attributes", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + assert html =~ ~s(aria-controls="field-visibility-menu") + assert html =~ ~s(aria-haspopup="menu") + assert html =~ ~s(role="button") + end + + test "menu items have proper ARIA attributes when open", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Open dropdown + view + |> element("button[aria-controls='field-visibility-menu']") + |> render_click() + + html = render(view) + + assert html =~ ~s(role="menu") + assert html =~ ~s(role="menuitemcheckbox") + assert html =~ ~s(aria-checked) + end + + test "keyboard navigation works", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Open dropdown + view + |> element("button[aria-controls='field-visibility-menu']") + |> render_click() + + # Check that elements are keyboard accessible + html = render(view) + assert html =~ ~s(tabindex="0") + # Check that keyboard events are supported + assert html =~ ~s(phx-keydown="select_item") + assert html =~ ~s(phx-key="Enter Space") + end + + test "keyboard activation with Enter key works", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Verify email is visible initially + html = render(view) + assert html =~ "alice@example.com" + + # Open dropdown + view + |> element("button[aria-controls='field-visibility-menu']") + |> render_click() + + # Simulate Enter key press on email field button + view + |> element("button[phx-click='select_item'][phx-value-item='email']") + |> render_keydown("Enter") + + # Wait for update + :timer.sleep(100) + + # Email should no longer be visible + html = render(view) + refute html =~ "alice@example.com" + end + + test "keyboard activation with Space key works", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Verify email is visible initially + html = render(view) + assert html =~ "alice@example.com" + + # Open dropdown + view + |> element("button[aria-controls='field-visibility-menu']") + |> render_click() + + # Simulate Space key press on email field button + view + |> element("button[phx-click='select_item'][phx-value-item='email']") + |> render_keydown(" ") + + # Wait for update + :timer.sleep(100) + + # Email should no longer be visible + html = render(view) + refute html =~ "alice@example.com" + end + end +end