diff --git a/lib/membership/member.ex b/lib/membership/member.ex index da69861..bcd505e 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -42,6 +42,10 @@ defmodule Mv.Membership.Member do @member_search_limit 10 @default_similarity_threshold 0.2 + # Use constants from Mv.Constants for member fields + # This ensures consistency across the codebase + @member_fields Mv.Constants.member_fields() + postgres do table "members" repo Mv.Repo @@ -58,21 +62,7 @@ defmodule Mv.Membership.Member do # user_id is NOT in accept list to prevent direct foreign key manipulation argument :user, :map, allow_nil?: true - accept [ - :first_name, - :last_name, - :email, - :birth_date, - :paid, - :phone_number, - :join_date, - :exit_date, - :notes, - :city, - :street, - :house_number, - :postal_code - ] + accept @member_fields change manage_relationship(:custom_field_values, type: :create) @@ -105,21 +95,7 @@ defmodule Mv.Membership.Member do # user_id is NOT in accept list to prevent direct foreign key manipulation argument :user, :map, allow_nil?: true - accept [ - :first_name, - :last_name, - :email, - :birth_date, - :paid, - :phone_number, - :join_date, - :exit_date, - :notes, - :city, - :street, - :house_number, - :postal_code - ] + accept @member_fields change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create) diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index cb3691b..f5a708b 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -53,6 +53,7 @@ defmodule Mv.Membership do # It's only used internally as fallback in get_settings/0 # Settings should be created via seed script define :update_settings, action: :update + define :update_member_field_visibility, action: :update_member_field_visibility end end @@ -123,4 +124,37 @@ defmodule Mv.Membership do |> Ash.Changeset.for_update(:update, attrs) |> Ash.update(domain: __MODULE__) end + + @doc """ + Updates the member field visibility configuration. + + This is a specialized action for updating only the member field visibility settings. + It validates that all keys are valid member fields and all values are booleans. + + ## Parameters + + - `settings` - The settings record to update + - `visibility_config` - A map of member field names (strings) to boolean visibility values + (e.g., `%{"street" => false, "house_number" => false}`) + + ## Returns + + - `{:ok, updated_settings}` - Successfully updated settings + - `{:error, error}` - Validation or update error + + ## Examples + + iex> {:ok, settings} = Mv.Membership.get_settings() + iex> {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false}) + iex> updated.member_field_visibility + %{"street" => false, "house_number" => false} + + """ + def update_member_field_visibility(settings, visibility_config) do + settings + |> Ash.Changeset.for_update(:update_member_field_visibility, %{ + member_field_visibility: visibility_config + }) + |> Ash.update(domain: __MODULE__) + end end diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index 38624dc..52c0328 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -9,6 +9,8 @@ defmodule Mv.Membership.Setting do ## Attributes - `club_name` - The name of the association/club (required, cannot be empty) + - `member_field_visibility` - JSONB map storing visibility configuration for member fields + (e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`. ## Singleton Pattern This resource uses a singleton pattern - there should only be one settings record. @@ -28,6 +30,9 @@ defmodule Mv.Membership.Setting do # Update club name {:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Name"}) + + # Update member field visibility + {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false}) """ use Ash.Resource, domain: Mv.Membership, @@ -49,18 +54,65 @@ defmodule Mv.Membership.Setting do # Used only as fallback in get_settings/0 if settings don't exist # Settings should normally be created via seed script create :create do - accept [:club_name] + accept [:club_name, :member_field_visibility] end update :update do primary? true - accept [:club_name] + require_atomic? false + accept [:club_name, :member_field_visibility] + end + + update :update_member_field_visibility do + description "Updates the visibility configuration for member fields in the overview" + require_atomic? false + accept [:member_field_visibility] end end validations do validate present(:club_name), on: [:create, :update] validate string_length(:club_name, min: 1), on: [:create, :update] + + # Validate member_field_visibility map structure and content + validate fn changeset, _context -> + visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility) + + if visibility && is_map(visibility) do + # Validate all values are booleans + invalid_values = + Enum.filter(visibility, fn {_key, value} -> + not is_boolean(value) + end) + + # Validate all keys are valid member fields + valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + + invalid_keys = + Enum.filter(visibility, fn {key, _value} -> + key not in valid_field_strings + end) + |> Enum.map(fn {key, _value} -> key end) + + cond do + not Enum.empty?(invalid_values) -> + {:error, + field: :member_field_visibility, + message: "All values in member_field_visibility must be booleans"} + + not Enum.empty?(invalid_keys) -> + {:error, + field: :member_field_visibility, + message: "Invalid member field keys: #{inspect(invalid_keys)}"} + + true -> + :ok + end + else + :ok + end + end, + on: [:create, :update] end attributes do @@ -75,6 +127,12 @@ defmodule Mv.Membership.Setting do min_length: 1 ] + attribute :member_field_visibility, :map, + allow_nil?: true, + public?: true, + description: + "Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans." + timestamps() end end diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex new file mode 100644 index 0000000..cd8d3a4 --- /dev/null +++ b/lib/mv/constants.ex @@ -0,0 +1,23 @@ +defmodule Mv.Constants do + @moduledoc """ + Module for defining constants and atoms. + """ + + @member_fields [ + :first_name, + :last_name, + :email, + :birth_date, + :paid, + :phone_number, + :join_date, + :exit_date, + :notes, + :city, + :street, + :house_number, + :postal_code + ] + + def member_fields, do: @member_fields +end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 4654b52..fcdeedd 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -30,11 +30,18 @@ defmodule MvWeb.MemberLive.Index do require Ash.Query import Ash.Expr + alias Mv.Membership alias MvWeb.MemberLive.Index.Formatter # Prefix used in sort field names for custom fields (e.g., "custom_field_") @custom_field_prefix "custom_field_" + # Member fields that are loaded for the overview + # Uses constants from Mv.Constants to ensure consistency + # Note: :id is always included for member identification + # All member fields are loaded, but visibility is controlled via settings + @overview_fields [:id | Mv.Constants.member_fields()] + @doc """ Initializes the LiveView state. @@ -53,6 +60,14 @@ defmodule MvWeb.MemberLive.Index do |> Ash.Query.sort(name: :asc) |> Ash.read!() + # Load settings once to avoid N+1 queries + settings = + case Membership.get_settings() do + {:ok, s} -> s + # Fallback if settings can't be loaded + {:error, _} -> %{member_field_visibility: %{}} + end + socket = socket |> assign(:page_title, gettext("Members")) @@ -62,6 +77,7 @@ defmodule MvWeb.MemberLive.Index do |> assign(:paid_filter, nil) |> assign(:selected_members, MapSet.new()) |> assign(:custom_fields_visible, custom_fields_visible) + |> assign(:member_fields_visible, get_visible_member_fields(settings)) # We call handle params to use the query from the URL {:ok, socket} @@ -416,19 +432,7 @@ defmodule MvWeb.MemberLive.Index do query = Mv.Membership.Member |> Ash.Query.new() - |> Ash.Query.select([ - :id, - :first_name, - :last_name, - :email, - :street, - :house_number, - :postal_code, - :city, - :phone_number, - :join_date, - :paid - ]) + |> Ash.Query.select(@overview_fields) # Load custom field values for visible custom fields custom_field_ids_list = Enum.map(socket.assigns.custom_fields_visible, & &1.id) @@ -558,18 +562,13 @@ defmodule MvWeb.MemberLive.Index do defp maybe_sort(query, _, _, _), do: {query, false} # Validate that a field is sortable + # Uses member fields from constants, but excludes fields that don't make sense to sort + # (e.g., :notes is too long, :paid is boolean and not very useful for sorting) defp valid_sort_field?(field) when is_atom(field) do - valid_fields = [ - :first_name, - :last_name, - :email, - :street, - :house_number, - :postal_code, - :city, - :phone_number, - :join_date - ] + # All member fields are sortable, but we exclude some that don't make sense + # :id is not in member_fields, but we don't want to sort by it anyway + non_sortable_fields = [:notes, :paid] + valid_fields = Mv.Constants.member_fields() -- non_sortable_fields field in valid_fields or custom_field_sort?(field) end @@ -899,4 +898,32 @@ defmodule MvWeb.MemberLive.Index do "#{name} <#{member.email}>" end end + + # Gets the list of member fields that should be visible in the overview. + # + # Reads the visibility configuration from Settings and returns only the fields + # where show_in_overview is true. Fields not configured in settings default to true. + # + # Performance: This function uses the already-loaded settings to avoid N+1 queries. + # Settings should be loaded once in mount/3 and passed to this function. + # + # Parameters: + # - `settings` - The settings struct loaded from the database + # + # Returns a list of atoms representing visible member field names. + # + # Fields are read from the global Constants module. + @spec get_visible_member_fields(map()) :: [atom()] + defp get_visible_member_fields(settings) do + # Get all eligible fields from the global constants + all_fields = Mv.Constants.member_fields() + + # JSONB stores keys as strings + visibility_config = settings.member_field_visibility || %{} + + # Filter to only return visible fields + Enum.filter(all_fields, fn field -> + Map.get(visibility_config, Atom.to_string(field), true) + end) + end end diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 4050444..58e22b6 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -97,6 +97,7 @@ <:col :let={member} + :if={:email in @member_fields_visible} label={ ~H""" <.live_component @@ -114,6 +115,7 @@ <:col :let={member} + :if={:street in @member_fields_visible} label={ ~H""" <.live_component @@ -131,6 +133,7 @@ <:col :let={member} + :if={:house_number in @member_fields_visible} label={ ~H""" <.live_component @@ -148,6 +151,7 @@ <:col :let={member} + :if={:postal_code in @member_fields_visible} label={ ~H""" <.live_component @@ -165,6 +169,7 @@ <:col :let={member} + :if={:city in @member_fields_visible} label={ ~H""" <.live_component @@ -182,6 +187,7 @@ <:col :let={member} + :if={:phone_number in @member_fields_visible} label={ ~H""" <.live_component @@ -199,6 +205,7 @@ <:col :let={member} + :if={:join_date in @member_fields_visible} label={ ~H""" <.live_component diff --git a/priv/repo/migrations/20251201115939_add_member_field_visibility_to_settings.exs b/priv/repo/migrations/20251201115939_add_member_field_visibility_to_settings.exs new file mode 100644 index 0000000..6d278fb --- /dev/null +++ b/priv/repo/migrations/20251201115939_add_member_field_visibility_to_settings.exs @@ -0,0 +1,21 @@ +defmodule Mv.Repo.Migrations.AddMemberFieldVisibilityToSettings do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:settings) do + add :member_field_visibility, :map + end + end + + def down do + alter table(:settings) do + remove :member_field_visibility + end + end +end diff --git a/priv/resource_snapshots/repo/custom_fields/20251201115939.json b/priv/resource_snapshots/repo/custom_fields/20251201115939.json new file mode 100644 index 0000000..fabd84b --- /dev/null +++ b/priv/resource_snapshots/repo/custom_fields/20251201115939.json @@ -0,0 +1,144 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "slug", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "value_type", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "immutable", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "required", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "show_in_overview", + "type": "boolean" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "D31160C95D3D32BA715D493DE2D2B8D6572E0EC68AE14B928D99975BC8A81542", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "custom_fields_unique_name_index", + "keys": [ + { + "type": "atom", + "value": "name" + } + ], + "name": "unique_name", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "custom_fields_unique_slug_index", + "keys": [ + { + "type": "atom", + "value": "slug" + } + ], + "name": "unique_slug", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "custom_fields" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/settings/20251201115939.json b/priv/resource_snapshots/repo/settings/20251201115939.json new file mode 100644 index 0000000..4e635c4 --- /dev/null +++ b/priv/resource_snapshots/repo/settings/20251201115939.json @@ -0,0 +1,79 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "club_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "member_field_visibility", + "type": "map" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "F2823210AA9E6476074A218375F64CD80E7F9E04EECC4E94D4C7FD31A773C016", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "settings" +} \ No newline at end of file diff --git a/test/membership/member_field_visibility_test.exs b/test/membership/member_field_visibility_test.exs new file mode 100644 index 0000000..9963169 --- /dev/null +++ b/test/membership/member_field_visibility_test.exs @@ -0,0 +1,14 @@ +defmodule Mv.Membership.MemberFieldVisibilityTest do + @moduledoc """ + Tests for member field visibility configuration. + + Tests cover: + - Member fields are visible by default (show_in_overview: true) + - Member fields can be hidden (show_in_overview: false) + - Checking if a specific field is visible + - Configuration is stored in Settings resource + """ + use Mv.DataCase, async: true + + alias Mv.Membership.Member +end diff --git a/test/mv_web/member_live/index_member_fields_display_test.exs b/test/mv_web/member_live/index_member_fields_display_test.exs new file mode 100644 index 0000000..6b4f50c --- /dev/null +++ b/test/mv_web/member_live/index_member_fields_display_test.exs @@ -0,0 +1,64 @@ +defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do + use MvWeb.ConnCase, async: true + import Phoenix.LiveViewTest + require Ash.Query + + alias Mv.Membership.Member + + setup do + {:ok, member1} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com", + street: "Main Street", + house_number: "123", + postal_code: "12345", + city: "Berlin", + phone_number: "+49123456789", + join_date: ~D[2020-01-15] + }) + |> Ash.create() + + {:ok, member2} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Bob", + last_name: "Brown", + email: "bob@example.com" + }) + |> Ash.create() + + %{ + member1: member1, + member2: member2 + } + end + + test "shows multiple members correctly", %{conn: conn, member1: m1, member2: m2} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + for m <- [m1, m2], field <- [m.first_name, m.last_name, m.email] do + assert html =~ field + end + end + + test "respects show_in_overview config", %{conn: conn, member1: m} do + {:ok, settings} = Mv.Membership.get_settings() + fields_to_hide = [:street, :house_number] + + {:ok, _} = + Mv.Membership.update_settings(settings, %{ + member_field_visibility: Map.new(fields_to_hide, &{Atom.to_string(&1), false}) + }) + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + assert html =~ "Email" + assert html =~ m.email + refute html =~ m.street + end +end