diff --git a/test/membership/custom_field_show_in_overview_test.exs b/test/membership/custom_field_show_in_overview_test.exs new file mode 100644 index 0000000..adac600 --- /dev/null +++ b/test/membership/custom_field_show_in_overview_test.exs @@ -0,0 +1,77 @@ +defmodule Mv.Membership.CustomFieldShowInOverviewTest do + @moduledoc """ + Tests for CustomField show_in_overview attribute. + + Tests cover: + - Creating custom fields with show_in_overview: true + - Creating custom fields with show_in_overview: false (default) + - Updating show_in_overview to true + - Updating show_in_overview to false + """ + use Mv.DataCase, async: true + + alias Mv.Membership.CustomField + + describe "show_in_overview attribute" do + test "creates custom field with show_in_overview: true" do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field_show", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + assert custom_field.show_in_overview == true + end + + test "creates custom field with show_in_overview: true (default)" do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field_hide", + value_type: :string + }) + |> Ash.create() + + assert custom_field.show_in_overview == true + end + + test "updates show_in_overview to true" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field_update", + value_type: :string, + show_in_overview: false + }) + |> Ash.create() + + assert {:ok, updated_field} = + custom_field + |> Ash.Changeset.for_update(:update, %{show_in_overview: true}) + |> Ash.update() + + assert updated_field.show_in_overview == true + end + + test "updates show_in_overview to false" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field_update2", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + assert {:ok, updated_field} = + custom_field + |> Ash.Changeset.for_update(:update, %{show_in_overview: false}) + |> Ash.update() + + assert updated_field.show_in_overview == false + end + end +end diff --git a/test/mv_web/member_live/index_custom_fields_accessibility_test.exs b/test/mv_web/member_live/index_custom_fields_accessibility_test.exs new file mode 100644 index 0000000..e4d174f --- /dev/null +++ b/test/mv_web/member_live/index_custom_fields_accessibility_test.exs @@ -0,0 +1,109 @@ +defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do + @moduledoc """ + Accessibility tests for custom field columns in the member overview. + + Tests cover: + - SortHeaderComponent for custom fields has correct ARIA labels + - Tab navigation works for custom field columns + - Screen reader announcements for sorting + """ + use MvWeb.ConnCase, async: true + import Phoenix.LiveViewTest + require Ash.Query + + alias Mv.Membership.{CustomField, CustomFieldValue, Member} + + setup do + # Create test member + {:ok, member} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + |> Ash.create() + + # Create custom field with show_in_overview: true + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "membership_number", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create custom field value + {:ok, _cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "A001"} + }) + |> Ash.create() + + %{member: member, field: field} + end + + test "sort header component for custom fields has correct ARIA labels", %{ + conn: conn, + field: field + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the sort button has aria-label + assert html =~ ~r/aria-label=["']Click to sort["']/i or + html =~ ~r/aria-label=["'].*sort.*["']/i + + # Check that data-testid is present for testing + assert html =~ ~r/data-testid=["']custom_field_#{field.id}["']/ + end + + test "sort header component shows correct ARIA label when sorted ascending", %{ + conn: conn, + field: field + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + + html = render(view) + + # Check that aria-label indicates ascending sort + assert html =~ ~r/aria-label=["'].*ascending.*["']/i + end + + test "sort header component shows correct ARIA label when sorted descending", %{ + conn: conn, + field: field + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + html = render(view) + + # Check that aria-label indicates descending sort + assert html =~ ~r/aria-label=["'].*descending.*["']/i + end + + test "custom field column header is keyboard accessible", %{conn: conn, field: field} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the sort button is a button element (keyboard accessible) + assert html =~ ~r/]*data-testid=["']custom_field_#{field.id}["']/ + + # Button should not have tabindex="-1" (which would remove from tab order) + refute html =~ ~r/tabindex=["']-1["']/ + end + + test "custom field column header has proper semantic structure", %{conn: conn, field: field} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that custom field name is displayed in the header + assert html =~ field.name + end +end diff --git a/test/mv_web/member_live/index_custom_fields_display_test.exs b/test/mv_web/member_live/index_custom_fields_display_test.exs new file mode 100644 index 0000000..7788c60 --- /dev/null +++ b/test/mv_web/member_live/index_custom_fields_display_test.exs @@ -0,0 +1,262 @@ +defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do + @moduledoc """ + Tests for displaying custom fields in the member overview. + + Tests cover: + - Custom fields with show_in_overview: true are displayed + - Custom fields with show_in_overview: false are not displayed + - Multiple custom fields with show_in_overview: true are all displayed + - Custom field values are correctly formatted for different types + - Members without custom field values show empty cell or "-" + """ + 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" + }) + |> Ash.create() + + {:ok, member2} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Bob", + last_name: "Brown", + email: "bob@example.com" + }) + |> Ash.create() + + # Create custom fields + {:ok, field_show_string} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "phone_mobile", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_hide} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "internal_note", + value_type: :string, + show_in_overview: false + }) + |> Ash.create() + + {:ok, field_show_integer} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "membership_number", + value_type: :integer, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_show_boolean} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "newsletter", + value_type: :boolean, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_show_date} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "birthday", + value_type: :date, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_show_email} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "secondary_email", + value_type: :email, + show_in_overview: true + }) + |> Ash.create() + + # Create custom field values for member1 + {:ok, _cfv1} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_string.id, + value: %{"_union_type" => "string", "_union_value" => "+49123456789"} + }) + |> Ash.create() + + {:ok, _cfv2} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_integer.id, + value: %{"_union_type" => "integer", "_union_value" => 12345} + }) + |> Ash.create() + + {:ok, _cfv3} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_boolean.id, + value: %{"_union_type" => "boolean", "_union_value" => true} + }) + |> Ash.create() + + {:ok, _cfv4} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_date.id, + value: %{"_union_type" => "date", "_union_value" => ~D[1990-05-15]} + }) + |> Ash.create() + + {:ok, _cfv5} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_email.id, + value: %{"_union_type" => "email", "_union_value" => "alice.private@example.com"} + }) + |> Ash.create() + + # Create hidden custom field value (should not be displayed) + {:ok, _cfv_hidden} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_hide.id, + value: %{"_union_type" => "string", "_union_value" => "Internal note"} + }) + |> Ash.create() + + %{ + member1: member1, + member2: member2, + field_show_string: field_show_string, + field_hide: field_hide, + field_show_integer: field_show_integer, + field_show_boolean: field_show_boolean, + field_show_date: field_show_date, + field_show_email: field_show_email + } + end + + test "displays custom field with show_in_overview: true", %{ + conn: conn, + member1: _member1, + field_show_string: field + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the custom field column header is displayed + assert html =~ field.name + + # Check that the value is displayed + assert html =~ "+49123456789" + end + + test "does not display custom field with show_in_overview: false", %{ + conn: conn, + member1: _member1, + field_hide: field + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the hidden custom field column header is NOT displayed + refute html =~ field.name + + # Check that the value is NOT displayed + refute html =~ "Internal note" + end + + test "displays multiple custom fields with show_in_overview: true", %{ + conn: conn, + field_show_string: field_string, + field_show_integer: field_integer, + field_show_boolean: field_boolean + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that all visible custom field column headers are displayed + assert html =~ field_string.name + assert html =~ field_integer.name + assert html =~ field_boolean.name + end + + test "formats string custom field values correctly", %{conn: conn, member1: _member1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + assert html =~ "+49123456789" + end + + test "formats integer custom field values correctly", %{conn: conn, member1: _member1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + assert html =~ "12345" + end + + test "formats boolean custom field values correctly", %{conn: conn, member1: _member1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Boolean should be displayed as "Yes" or "No" or similar + # Check for true representation + assert html =~ "true" or html =~ "Yes" or html =~ "Ja" + end + + test "formats date custom field values correctly", %{conn: conn, member1: _member1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Date should be displayed in readable format + assert html =~ "1990" or html =~ "1990-05-15" or html =~ "15.05.1990" + end + + test "formats email custom field values correctly", %{conn: conn, member1: _member1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + assert html =~ "alice.private@example.com" + end + + test "shows empty cell or placeholder for members without custom field values", %{ + conn: conn, + member2: _member2, + field_show_string: field + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # The custom field column should exist + assert html =~ field.name + + # Member2 should have an empty cell for this field + # We check that member2's row exists but doesn't have the value + assert html =~ "Bob Brown" + # The value should not appear for member2 (only for member1) + # We check that the value appears somewhere (for member1) but member2 row should have "-" + assert html =~ "+49123456789" + end +end diff --git a/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs b/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs new file mode 100644 index 0000000..9d44c40 --- /dev/null +++ b/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs @@ -0,0 +1,174 @@ +defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do + @moduledoc """ + Edge case tests for custom fields in the member overview. + + Tests cover: + - Custom field without values (all members have no value) + - Very long custom field values are correctly displayed + """ + use MvWeb.ConnCase, async: true + import Phoenix.LiveViewTest + require Ash.Query + + alias Mv.Membership.{CustomField, Member} + + test "displays custom field column even when no members have values", %{conn: conn} do + # Create test members without custom field values + {:ok, _member1} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + |> Ash.create() + + {:ok, _member2} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Bob", + last_name: "Brown", + email: "bob@example.com" + }) + |> Ash.create() + + # Create custom field with show_in_overview: true but no values + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "membership_number", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the custom field column header is still displayed + assert html =~ field.name + end + + test "displays very long custom field values correctly", %{conn: conn} do + # Create test member + {:ok, member} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + |> Ash.create() + + # Create custom field + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "long_note", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create very long value (but within limits) + long_value = String.duplicate("A", 500) + + {:ok, _cfv} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => long_value} + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the value is displayed (may be truncated in UI, but should be present) + # We check for at least part of the value + assert html =~ "A" or html =~ long_value + end + + test "handles multiple custom fields with show_in_overview correctly", %{conn: conn} do + # Create test member + {:ok, member} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + |> Ash.create() + + # Create multiple custom fields with show_in_overview: true + {:ok, field1} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "field1", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field2} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "field2", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field3} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "field3", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create values for all fields + {:ok, _cfv1} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field1.id, + value: %{"_union_type" => "string", "_union_value" => "Value1"} + }) + |> Ash.create() + + {:ok, _cfv2} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field2.id, + value: %{"_union_type" => "string", "_union_value" => "Value2"} + }) + |> Ash.create() + + {:ok, _cfv3} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field3.id, + value: %{"_union_type" => "string", "_union_value" => "Value3"} + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that all custom field columns are displayed + assert html =~ field1.name + assert html =~ field2.name + assert html =~ field3.name + + # Check that all values are displayed + assert html =~ "Value1" + assert html =~ "Value2" + assert html =~ "Value3" + end +end + diff --git a/test/mv_web/member_live/index_custom_fields_sorting_test.exs b/test/mv_web/member_live/index_custom_fields_sorting_test.exs new file mode 100644 index 0000000..e1c99b2 --- /dev/null +++ b/test/mv_web/member_live/index_custom_fields_sorting_test.exs @@ -0,0 +1,446 @@ +defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do + @moduledoc """ + Tests for sorting by custom fields in the member overview. + + Tests cover: + - Sorting by custom field (ascending) + - Sorting by custom field (descending) + - Sorting by custom field works with search + - Sorting by custom field works with URL parameters + - Sorting by custom field works with other columns + """ + 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" + }) + |> Ash.create() + + {:ok, member2} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Bob", + last_name: "Brown", + email: "bob@example.com" + }) + |> Ash.create() + + {:ok, member3} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Charlie", + last_name: "Clark", + email: "charlie@example.com" + }) + |> Ash.create() + + # Create custom field with show_in_overview: true + {:ok, field_string} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "membership_number", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_integer} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "priority", + value_type: :integer, + 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: field_string.id, + value: %{"_union_type" => "string", "_union_value" => "A001"} + }) + |> Ash.create() + + {:ok, _cfv2} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member2.id, + custom_field_id: field_string.id, + value: %{"_union_type" => "string", "_union_value" => "C003"} + }) + |> Ash.create() + + {:ok, _cfv3} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member3.id, + custom_field_id: field_string.id, + value: %{"_union_type" => "string", "_union_value" => "B002"} + }) + |> Ash.create() + + {:ok, _cfv4} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_integer.id, + value: %{"_union_type" => "integer", "_union_value" => 10} + }) + |> Ash.create() + + {:ok, _cfv5} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member2.id, + custom_field_id: field_integer.id, + value: %{"_union_type" => "integer", "_union_value" => 30} + }) + |> Ash.create() + + {:ok, _cfv6} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member3.id, + custom_field_id: field_integer.id, + value: %{"_union_type" => "integer", "_union_value" => 20} + }) + |> Ash.create() + + %{ + member1: member1, + member2: member2, + member3: member3, + field_string: field_string, + field_integer: field_integer + } + end + + test "sorts by custom field ascending", %{conn: conn, field_string: field} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Click on custom field column header to sort + view + |> element("[data-testid='custom_field_#{field.id}']") + |> render_click() + + # Check URL was updated + assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + + # Verify sort state + assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='ascending']") + end + + test "sorts by custom field descending", %{conn: conn, field_string: field} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?sort_field=custom_field_#{field.id}&sort_order=asc") + + # Click again to toggle to descending + view + |> element("[data-testid='custom_field_#{field.id}']") + |> render_click() + + # Check URL was updated + assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + # Verify sort state + assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='descending']") + end + + test "sorting by custom field works with search", %{conn: conn, field_string: field} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=Alice") + + # Click on custom field column header to sort + view + |> element("[data-testid='custom_field_#{field.id}']") + |> render_click() + + # Check URL maintains search query + assert_patch(view, "/members?query=Alice&sort_field=custom_field_#{field.id}&sort_order=asc") + end + + test "sorting by custom field works with URL parameters", %{conn: conn, field_string: field} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + # Check that the sort state is correctly applied + assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='descending']") + end + + test "clicking different custom field column resets order to ascending", %{ + conn: conn, + field_string: field_string, + field_integer: field_integer + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field_string.id}&sort_order=desc") + + # Click on a different custom field column + view + |> element("[data-testid='custom_field_#{field_integer.id}']") + |> render_click() + + assert_patch(view, "/members?query=&sort_field=custom_field_#{field_integer.id}&sort_order=asc") + end + + test "clicking regular column after custom field column works", %{ + conn: conn, + field_string: field + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + # Click on email column + view + |> element("[data-testid='email']") + |> render_click() + + assert_patch(view, "/members?query=&sort_field=email&sort_order=asc") + end + + test "clicking custom field column after regular column works", %{ + conn: conn, + field_string: field + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc") + + # Click on custom field column + view + |> element("[data-testid='custom_field_#{field.id}']") + |> render_click() + + assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + end + + test "NULL values and empty strings are always sorted last (ASC)", %{conn: conn} do + # Create additional members with NULL and empty string values + {:ok, member_with_value} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithValue", + last_name: "Test", + email: "withvalue@example.com" + }) + |> Ash.create() + + {:ok, member_with_empty} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithEmpty", + last_name: "Test", + email: "withempty@example.com" + }) + |> Ash.create() + + {:ok, member_with_null} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithNull", + last_name: "Test", + email: "withnull@example.com" + }) + |> Ash.create() + + {:ok, member_with_another_value} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "AnotherValue", + last_name: "Test", + email: "another@example.com" + }) + |> Ash.create() + + # Create custom field + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create values: one with actual value, one with empty string, one with NULL (no value), another with value + {:ok, _cfv1} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_value.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "Zebra"} + }) + |> Ash.create() + + {:ok, _cfv2} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_empty.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => ""} + }) + |> Ash.create() + + # member_with_null has no custom field value (NULL) + + {:ok, _cfv3} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_another_value.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "Apple"} + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + + html = render(view) + + # Find positions of member first names in the HTML to verify sort order + apple_pos = :binary.match(html, member_with_another_value.first_name) + zebra_pos = :binary.match(html, member_with_value.first_name) + empty_pos = :binary.match(html, member_with_empty.first_name) + null_pos = :binary.match(html, member_with_null.first_name) + + assert apple_pos != :nomatch, "AnotherValue (Apple) should be in HTML" + assert zebra_pos != :nomatch, "WithValue (Zebra) should be in HTML" + assert empty_pos != :nomatch, "WithEmpty should be in HTML" + assert null_pos != :nomatch, "WithNull should be in HTML" + + {apple_idx, _} = apple_pos + {zebra_idx, _} = zebra_pos + {empty_idx, _} = empty_pos + {null_idx, _} = null_pos + + # In ASC order: Apple should come before Zebra + assert apple_idx < zebra_idx, "Apple should come before Zebra in ASC order" + + # NULL and empty should come after all values + assert apple_idx < empty_idx, "Apple should come before empty value" + assert apple_idx < null_idx, "Apple should come before NULL value" + assert zebra_idx < empty_idx, "Zebra should come before empty value" + assert zebra_idx < null_idx, "Zebra should come before NULL value" + end + + test "NULL values and empty strings are always sorted last (DESC)", %{conn: conn} do + # Create additional members with NULL and empty string values + {:ok, member_with_value} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithValue", + last_name: "Test", + email: "withvalue@example.com" + }) + |> Ash.create() + + {:ok, member_with_empty} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithEmpty", + last_name: "Test", + email: "withempty@example.com" + }) + |> Ash.create() + + {:ok, member_with_null} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithNull", + last_name: "Test", + email: "withnull@example.com" + }) + |> Ash.create() + + {:ok, member_with_another_value} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "AnotherValue", + last_name: "Test", + email: "another@example.com" + }) + |> Ash.create() + + # Create custom field + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create values: one with actual value, one with empty string, one with NULL (no value), another with value + {:ok, _cfv1} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_value.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "Apple"} + }) + |> Ash.create() + + {:ok, _cfv2} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_empty.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => ""} + }) + |> Ash.create() + + # member_with_null has no custom field value (NULL) + + {:ok, _cfv3} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_another_value.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "Zebra"} + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + html = render(view) + + # Find positions of member first names in the HTML to verify sort order + apple_pos = :binary.match(html, member_with_value.first_name) + zebra_pos = :binary.match(html, member_with_another_value.first_name) + empty_pos = :binary.match(html, member_with_empty.first_name) + null_pos = :binary.match(html, member_with_null.first_name) + + assert apple_pos != :nomatch, "WithValue (Apple) should be in HTML" + assert zebra_pos != :nomatch, "AnotherValue (Zebra) should be in HTML" + assert empty_pos != :nomatch, "WithEmpty should be in HTML" + assert null_pos != :nomatch, "WithNull should be in HTML" + + {apple_idx, _} = apple_pos + {zebra_idx, _} = zebra_pos + {empty_idx, _} = empty_pos + {null_idx, _} = null_pos + + # In DESC order: Zebra should come before Apple + assert zebra_idx < apple_idx, "Zebra should come before Apple in DESC order" + + # NULL and empty should come after all values + assert zebra_idx < empty_idx, "Zebra should come before empty value" + assert zebra_idx < null_idx, "Zebra should come before NULL value" + assert apple_idx < empty_idx, "Apple should come before empty value" + assert apple_idx < null_idx, "Apple should come before NULL value" + end +end