diff --git a/lib/membership/member.ex b/lib/membership/member.ex index bcd505e..da69861 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -42,10 +42,6 @@ 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 @@ -62,7 +58,21 @@ 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 @member_fields + accept [ + :first_name, + :last_name, + :email, + :birth_date, + :paid, + :phone_number, + :join_date, + :exit_date, + :notes, + :city, + :street, + :house_number, + :postal_code + ] change manage_relationship(:custom_field_values, type: :create) @@ -95,7 +105,21 @@ 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 @member_fields + accept [ + :first_name, + :last_name, + :email, + :birth_date, + :paid, + :phone_number, + :join_date, + :exit_date, + :notes, + :city, + :street, + :house_number, + :postal_code + ] 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 f5a708b..cb3691b 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -53,7 +53,6 @@ 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 @@ -124,37 +123,4 @@ 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 52c0328..38624dc 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -9,8 +9,6 @@ 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. @@ -30,9 +28,6 @@ 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, @@ -54,65 +49,18 @@ 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, :member_field_visibility] + accept [:club_name] end update :update do primary? true - 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] + accept [:club_name] 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 @@ -127,12 +75,6 @@ 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 deleted file mode 100644 index cd8d3a4..0000000 --- a/lib/mv/constants.ex +++ /dev/null @@ -1,23 +0,0 @@ -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 fcdeedd..4654b52 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -30,18 +30,11 @@ 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. @@ -60,14 +53,6 @@ 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")) @@ -77,7 +62,6 @@ 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} @@ -432,7 +416,19 @@ defmodule MvWeb.MemberLive.Index do query = Mv.Membership.Member |> Ash.Query.new() - |> Ash.Query.select(@overview_fields) + |> Ash.Query.select([ + :id, + :first_name, + :last_name, + :email, + :street, + :house_number, + :postal_code, + :city, + :phone_number, + :join_date, + :paid + ]) # Load custom field values for visible custom fields custom_field_ids_list = Enum.map(socket.assigns.custom_fields_visible, & &1.id) @@ -562,13 +558,18 @@ 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 - # 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 + valid_fields = [ + :first_name, + :last_name, + :email, + :street, + :house_number, + :postal_code, + :city, + :phone_number, + :join_date + ] field in valid_fields or custom_field_sort?(field) end @@ -898,32 +899,4 @@ 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 58e22b6..4050444 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -97,7 +97,6 @@ <:col :let={member} - :if={:email in @member_fields_visible} label={ ~H""" <.live_component @@ -115,7 +114,6 @@ <:col :let={member} - :if={:street in @member_fields_visible} label={ ~H""" <.live_component @@ -133,7 +131,6 @@ <:col :let={member} - :if={:house_number in @member_fields_visible} label={ ~H""" <.live_component @@ -151,7 +148,6 @@ <:col :let={member} - :if={:postal_code in @member_fields_visible} label={ ~H""" <.live_component @@ -169,7 +165,6 @@ <:col :let={member} - :if={:city in @member_fields_visible} label={ ~H""" <.live_component @@ -187,7 +182,6 @@ <:col :let={member} - :if={:phone_number in @member_fields_visible} label={ ~H""" <.live_component @@ -205,7 +199,6 @@ <: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 deleted file mode 100644 index 6d278fb..0000000 --- a/priv/repo/migrations/20251201115939_add_member_field_visibility_to_settings.exs +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index fabd84b..0000000 --- a/priv/resource_snapshots/repo/custom_fields/20251201115939.json +++ /dev/null @@ -1,144 +0,0 @@ -{ - "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 deleted file mode 100644 index 4e635c4..0000000 --- a/priv/resource_snapshots/repo/settings/20251201115939.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "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 deleted file mode 100644 index 9963169..0000000 --- a/test/membership/member_field_visibility_test.exs +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 6b4f50c..0000000 --- a/test/mv_web/member_live/index_member_fields_display_test.exs +++ /dev/null @@ -1,64 +0,0 @@ -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