Implements settings for member fields closes #223 #300

Merged
carla merged 26 commits from feature/223_memberfields_settings into main 2026-01-12 13:24:54 +01:00
11 changed files with 63 additions and 239 deletions
Showing only changes of commit 36776f8e28 - Show all commits

View file

@ -8,9 +8,9 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
use Gettext, backend: MvWeb.Gettext use Gettext, backend: MvWeb.Gettext
alias Mv.Membership.Member
alias Mv.MembershipFees.CalendarCycles alias Mv.MembershipFees.CalendarCycles
alias Mv.MembershipFees.MembershipFeeCycle alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.Membership.Member
@doc """ @doc """
Formats a decimal amount as currency string. Formats a decimal amount as currency string.

View file

@ -18,8 +18,8 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
use MvWeb, :live_component use MvWeb, :live_component
alias Mv.Membership alias Mv.Membership
alias MvWeb.Translations.MemberFields
alias MvWeb.Translations.FieldTypes alias MvWeb.Translations.FieldTypes
alias MvWeb.Translations.MemberFields
@required_fields [:first_name, :last_name, :email] @required_fields [:first_name, :last_name, :email]

View file

@ -12,8 +12,8 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
use MvWeb, :live_component use MvWeb, :live_component
alias Mv.Membership alias Mv.Membership
alias MvWeb.Translations.MemberFields
alias MvWeb.Translations.FieldTypes alias MvWeb.Translations.FieldTypes
alias MvWeb.Translations.MemberFields
@impl true @impl true
def render(assigns) do def render(assigns) do
@ -109,6 +109,9 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
@impl true @impl true
def update(assigns, socket) do def update(assigns, socket) do
# Track previous show_form state to detect when form is closed
previous_show_form = Map.get(socket.assigns, :show_form, false)
# If show_form is explicitly provided in assigns, reset editing state # If show_form is explicitly provided in assigns, reset editing state
socket = socket =
if Map.has_key?(assigns, :show_form) and assigns.show_form == false do if Map.has_key?(assigns, :show_form) and assigns.show_form == false do
@ -119,6 +122,13 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
socket socket
end end
# Detect when form is closed (show_form changes from true to false)
new_show_form = Map.get(assigns, :show_form, false)
if previous_show_form and not new_show_form do
send(self(), {:editing_section_changed, nil})
end
{:ok, {:ok,
socket socket
|> assign(assigns) |> assign(assigns)
@ -136,6 +146,11 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
if field_string in valid_fields do if field_string in valid_fields do
field_atom = String.to_existing_atom(field_string) field_atom = String.to_existing_atom(field_string)
# Only send event if form was not already open
if not socket.assigns[:show_form] do
send(self(), {:editing_section_changed, :member_fields})
end
{:noreply, {:noreply,
socket socket
|> assign(:show_form, true) |> assign(:show_form, true)

View file

@ -31,10 +31,10 @@ defmodule MvWeb.MemberLive.Index do
import Ash.Expr import Ash.Expr
alias Mv.Membership alias Mv.Membership
alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.Helpers.DateFormatter alias MvWeb.Helpers.DateFormatter
alias MvWeb.MemberLive.Index.FieldSelection alias MvWeb.MemberLive.Index.FieldSelection
alias MvWeb.MemberLive.Index.FieldVisibility alias MvWeb.MemberLive.Index.FieldVisibility
alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.MembershipFeeStatus
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>") # Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")

View file

@ -15,10 +15,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
require Ash.Query require Ash.Query
alias Mv.Membership alias Mv.Membership
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.CalendarCycles alias Mv.MembershipFees.CalendarCycles
alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
@impl true @impl true

View file

@ -15,9 +15,9 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
require Ash.Query require Ash.Query
alias Mv.Membership.Member
alias Mv.MembershipFees alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
@impl true @impl true

View file

@ -16,10 +16,10 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
require Ash.Query require Ash.Query
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership alias Mv.Membership
alias Mv.Membership.Member alias Mv.Membership.Member
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
@impl true @impl true

View file

@ -491,10 +491,39 @@ default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
case Membership.get_settings() do case Membership.get_settings() do
{:ok, existing_settings} -> {:ok, existing_settings} ->
# Settings exist, update if club_name is different from env var # Settings exist, update if club_name is different from env var
if existing_settings.club_name != default_club_name do # Also ensure exit_date is set to false by default if not already configured
{:ok, _updated} = updates =
Membership.update_settings(existing_settings, %{club_name: default_club_name}) %{}
|> then(fn acc ->
if existing_settings.club_name != default_club_name,
do: Map.put(acc, :club_name, default_club_name),
else: acc
end)
|> then(fn acc ->
visibility_config = existing_settings.member_field_visibility || %{}
# Ensure exit_date is set to false if not already configured
if not Map.has_key?(visibility_config, "exit_date") and
not Map.has_key?(visibility_config, :exit_date) do
updated_visibility = Map.put(visibility_config, "exit_date", false)
Map.put(acc, :member_field_visibility, updated_visibility)
else
acc
end
end)
if map_size(updates) > 0 do
{:ok, _updated} = Membership.update_settings(existing_settings, updates)
end end
{:ok, nil} ->
# Settings don't exist yet, create with exit_date defaulting to false
{:ok, _settings} =
Membership.Setting
|> Ash.Changeset.for_create(:create, %{
club_name: default_club_name,
member_field_visibility: %{"exit_date" => false}
})
|> Ash.create!()
end end
IO.puts("✅ Seeds completed successfully!") IO.puts("✅ Seeds completed successfully!")

View file

@ -13,14 +13,17 @@ defmodule Mv.Membership.MemberFieldVisibilityTest do
alias Mv.Membership.Member alias Mv.Membership.Member
describe "show_in_overview?/1" do describe "show_in_overview?/1" do
test "returns true for all member fields by default" do test "returns true for all member fields by default, except exit_date" do
# When no settings exist or member_field_visibility is not configured # When no settings exist or member_field_visibility is not configured
# Test with fields from constants # Test with fields from constants
# Note: exit_date defaults to false (hidden) by design
member_fields = Mv.Constants.member_fields() member_fields = Mv.Constants.member_fields()
Enum.each(member_fields, fn field -> Enum.each(member_fields, fn field ->
assert Member.show_in_overview?(field) == true, expected_visibility = if field == :exit_date, do: false, else: true
"Field #{field} should be visible by default"
assert Member.show_in_overview?(field) == expected_visibility,
"Field #{field} should be #{if expected_visibility, do: "visible", else: "hidden"} by default"
end) end)
end end

View file

@ -6,8 +6,6 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do
- Rendering all member fields from Mv.Constants.member_fields() - Rendering all member fields from Mv.Constants.member_fields()
- Displaying show_in_overview status as badge (Yes/No) - Displaying show_in_overview status as badge (Yes/No)
- Displaying required status for required fields (first_name, last_name, email) - Displaying required status for required fields (first_name, last_name, email)
- Toggle functionality to change show_in_overview flag
- Settings are correctly updated after toggle
- Current status is displayed based on settings.member_field_visibility - Current status is displayed based on settings.member_field_visibility
- Default status is "Yes" (visible) when not configured in settings - Default status is "Yes" (visible) when not configured in settings
""" """
@ -86,70 +84,6 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do
end end
end end
describe "toggle functionality" do
test "toggles field visibility from visible to hidden", %{conn: conn} do
# Start with field visible (default)
{:ok, settings} = Membership.get_settings()
{:ok, _updated} =
Membership.update_member_field_visibility(settings, %{"street" => true})
{:ok, view, _html} = live(conn, ~p"/settings")
# Find and click toggle button for street field
# This will fail until component is implemented
assert has_element?(view, "#member-field-street-toggle") or
has_element?(view, "[phx-click='toggle_field_visibility'][data-field='street']")
# Click toggle
view
|> element("#member-field-street-toggle")
|> render_click(%{"field" => "street"})
# Verify settings updated
{:ok, updated_settings} = Membership.get_settings()
visibility = updated_settings.member_field_visibility || %{}
assert Map.get(visibility, "street") == false
end
test "toggles field visibility from hidden to visible", %{conn: conn} do
# Start with field hidden
{:ok, settings} = Membership.get_settings()
{:ok, _updated} =
Membership.update_member_field_visibility(settings, %{"street" => false})
{:ok, view, _html} = live(conn, ~p"/settings")
# Click toggle to make visible
view
|> element("#member-field-street-toggle")
|> render_click(%{"field" => "street"})
# Verify settings updated
{:ok, updated_settings} = Membership.get_settings()
visibility = updated_settings.member_field_visibility || %{}
assert Map.get(visibility, "street") == true
end
test "sends message to parent LiveView after toggle", %{conn: conn} do
{:ok, settings} = Membership.get_settings()
{:ok, _updated} =
Membership.update_member_field_visibility(settings, %{"street" => true})
{:ok, view, _html} = live(conn, ~p"/settings")
# Toggle field
view
|> element("#member-field-street-toggle")
|> render_click(%{"field" => "street"})
# Check for flash message (handled by parent LiveView)
assert render(view) =~ "updated" or render(view) =~ "success"
end
end
describe "required fields" do describe "required fields" do
test "marks first_name as required", %{conn: conn} do test "marks first_name as required", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings") {:ok, _view, html} = live(conn, ~p"/settings")

View file

@ -1,157 +0,0 @@
defmodule MvWeb.MemberLive.IndexRequiredDisplayTest do
@moduledoc """
Tests for displaying "required" badge in member overview.
Tests cover:
- "required" badge for required member fields (first_name, last_name, email)
- "required" badge for required custom fields
- No "required" badge for optional member fields
- No "required" badge for optional custom fields
- Badge is positioned in column header
"""
# async: false to prevent PostgreSQL deadlocks when creating members and custom fields
use MvWeb.ConnCase, async: false
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 required custom field
{:ok, required_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "emergency_contact",
value_type: :string,
required: true,
show_in_overview: true
})
|> Ash.create()
# Create optional custom field
{:ok, optional_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "hobby",
value_type: :string,
required: false,
show_in_overview: true
})
|> Ash.create()
# Create custom field values
{:ok, _cfv1} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: required_field.id,
value: %{"_union_type" => "string", "_union_value" => "John Doe"}
})
|> Ash.create()
{:ok, _cfv2} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: optional_field.id,
value: %{"_union_type" => "string", "_union_value" => "Reading"}
})
|> Ash.create()
%{
member: member,
required_field: required_field,
optional_field: optional_field
}
end
describe "required badge for member fields" do
test "displays required badge for first_name column", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check that first_name column header has required badge
assert html =~ "first_name" or html =~ "First name" or html =~ "First Name"
# Should have required indicator in header
assert html =~ "required" or html =~ "Required"
end
test "displays required badge for last_name column", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check that last_name column header has required badge
assert html =~ "last_name" or html =~ "Last name" or html =~ "Last Name"
# Should have required indicator in header
assert html =~ "required" or html =~ "Required"
end
test "displays required badge for email column", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check that email column header has required badge
assert html =~ "email" or html =~ "Email"
# Should have required indicator in header
assert html =~ "required" or html =~ "Required"
end
test "does not display required badge for optional member fields", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Optional fields: street, city, phone_number, etc.
# These should not have required badge
# We check that street is present but doesn't have required indicator nearby
assert html =~ "street" or html =~ "Street"
end
end
describe "required badge for custom fields" do
test "displays required badge for required custom field column", %{
conn: conn,
required_field: field
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check that required custom field column header has required badge
assert html =~ field.name
# Should have required indicator in header
assert html =~ "required" or html =~ "Required"
end
test "does not display required badge for optional custom field column", %{
conn: conn,
optional_field: field
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check that optional custom field column header does not have required badge
assert html =~ field.name
# Should not have required indicator (or it should be clear it's optional)
end
end
describe "badge positioning" do
test "required badge is in column header, not in cell content", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Required badge should be in thead (header), not in tbody (data rows)
# This is verified by checking that required appears near column headers
assert html =~ "thead" or html =~ "th"
end
end
end