defmodule MvWeb.MemberLive.Index.FieldVisibility do @moduledoc """ Manages field visibility by merging user-specific selection with global settings. This module handles: - Getting all available fields (member fields + custom fields) - Merging user selection with global settings (user selection takes priority) - Falling back to global settings when no user selection exists - Converting between different field name formats (atoms vs strings) ## Field Naming Convention - **Member Fields**: Atoms (e.g., `:first_name`, `:email`) - **Custom Fields**: Strings with format `"custom_field_"` (e.g., `"custom_field_abc-123"`) ## Priority Order 1. User-specific selection (from URL/Session/Cookie) 2. Global settings (from database) 3. Default (all fields visible) ## Pseudo Member Fields Overview-only fields that are not in `Mv.Constants.member_fields()` (e.g. computed/UI-only). They appear in the field dropdown and in `member_fields_visible` but are not domain attributes. """ alias Mv.Membership.Helpers.VisibilityConfig # Single UI key for "Membership Fee Status"; only this appears in the dropdown. @pseudo_member_fields [:membership_fee_status] # Export/API may accept this as alias; must not appear in the UI options list. @export_only_alias :payment_status defp overview_member_fields do Mv.Constants.member_fields() ++ @pseudo_member_fields end @doc """ Gets all available fields for selection. Returns a list of field identifiers: - Member fields as atoms (e.g., `:first_name`, `:email`) - Custom fields as strings (e.g., `"custom_field_abc-123"`) ## Parameters - `custom_fields` - List of CustomField resources that are available ## Returns List of field identifiers (atoms and strings) """ @spec get_all_available_fields([struct()]) :: [atom() | String.t()] def get_all_available_fields(custom_fields) do member_fields = overview_member_fields() |> Enum.reject(fn field -> field == @export_only_alias end) custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}") member_fields ++ custom_field_names end @doc """ Merges user field selection with global settings. User selection takes priority over global settings. If a field is not in the user selection, the global setting is used. If a field is not in global settings, it defaults to `true` (visible). ## Parameters - `user_selection` - Map of field names (strings) to boolean visibility - `global_settings` - Settings struct with `member_field_visibility` field - `custom_fields` - List of CustomField resources ## Returns Map of field names (strings) to boolean visibility values ## Examples iex> user_selection = %{"first_name" => false} iex> settings = %{member_field_visibility: %{first_name: true, email: true}} iex> merge_with_global_settings(user_selection, settings, []) %{"first_name" => false, "email" => true} # User selection overrides global """ @spec merge_with_global_settings( %{String.t() => boolean()}, map(), [struct()] ) :: %{String.t() => boolean()} def merge_with_global_settings(user_selection, global_settings, custom_fields) do all_fields = get_all_available_fields(custom_fields) global_visibility = get_global_visibility_map(global_settings, custom_fields) Enum.reduce(all_fields, %{}, fn field, acc -> field_string = field_to_string(field) visibility = case Map.get(user_selection, field_string) do nil -> Map.get(global_visibility, field_string, true) user_value -> user_value end Map.put(acc, field_string, visibility) end) end @doc """ Gets the list of visible fields from a field selection map. Returns only fields where visibility is `true`. ## Parameters - `field_selection` - Map of field names to boolean visibility ## Returns List of field identifiers (atoms for member fields, strings for custom fields) ## Examples iex> selection = %{"first_name" => true, "email" => false, "street" => true} iex> get_visible_fields(selection) [:first_name, :street] """ @spec get_visible_fields(%{String.t() => boolean()}) :: [atom() | String.t()] def get_visible_fields(field_selection) when is_map(field_selection) do field_selection |> Enum.filter(fn {_field, visible} -> visible end) |> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end) |> Enum.uniq() end def get_visible_fields(_), do: [] @doc """ Gets visible member fields from field selection. Returns only member fields (atoms) that are visible. ## Examples iex> selection = %{"first_name" => true, "email" => true, "custom_field_123" => true} iex> get_visible_member_fields(selection) [:first_name, :email] """ @spec get_visible_member_fields(%{String.t() => boolean()}) :: [atom()] def get_visible_member_fields(field_selection) when is_map(field_selection) do member_fields = overview_member_fields() field_selection |> Enum.filter(fn {field_string, visible} -> field_atom = to_field_identifier(field_string) visible && field_atom in member_fields end) |> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end) |> Enum.uniq() end def get_visible_member_fields(_), do: [] @doc """ Returns the list of computed (UI-only) member field atoms. These fields are not in the database; they must not be used for Ash query select/sort. Use this to filter sort options and validate sort_field. """ @spec computed_member_fields() :: [atom()] def computed_member_fields, do: @pseudo_member_fields @doc """ Visible member fields that are real DB attributes (from `Mv.Constants.member_fields()`). Use for query select/sort. Not for rendering column visibility (use `get_visible_member_fields/1` for that). """ @spec get_visible_member_fields_db(%{String.t() => boolean()}) :: [atom()] def get_visible_member_fields_db(field_selection) when is_map(field_selection) do db_fields = MapSet.new(Mv.Constants.member_fields()) field_selection |> Enum.filter(fn {field_string, visible} -> field_atom = to_field_identifier(field_string) visible && field_atom in db_fields end) |> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end) |> Enum.uniq() end def get_visible_member_fields_db(_), do: [] @doc """ Visible member fields that are computed/UI-only (e.g. membership_fee_status). Use for rendering; do not use for query select or sort. """ @spec get_visible_member_fields_computed(%{String.t() => boolean()}) :: [atom()] def get_visible_member_fields_computed(field_selection) when is_map(field_selection) do computed_set = MapSet.new(@pseudo_member_fields) field_selection |> Enum.filter(fn {field_string, visible} -> field_atom = to_field_identifier(field_string) visible && field_atom in computed_set end) |> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end) |> Enum.uniq() end def get_visible_member_fields_computed(_), do: [] @doc """ Gets visible custom fields from field selection. Returns only custom field identifiers (strings) that are visible. ## Examples iex> selection = %{"first_name" => true, "custom_field_123" => true, "custom_field_456" => false} iex> get_visible_custom_fields(selection) ["custom_field_123"] """ @spec get_visible_custom_fields(%{String.t() => boolean()}) :: [String.t()] def get_visible_custom_fields(field_selection) when is_map(field_selection) do prefix = Mv.Constants.custom_field_prefix() field_selection |> Enum.filter(fn {field_string, visible} -> visible && String.starts_with?(field_string, prefix) end) |> Enum.map(fn {field_string, _visible} -> field_string end) end def get_visible_custom_fields(_), do: [] # Gets global visibility map from settings defp get_global_visibility_map(settings, custom_fields) do member_visibility = get_member_field_visibility_from_settings(settings) custom_field_visibility = get_custom_field_visibility(custom_fields) Map.merge(member_visibility, custom_field_visibility) end # Gets member field visibility from settings (domain fields from settings, pseudo fields default true) defp get_member_field_visibility_from_settings(settings) do visibility_config = VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{})) domain_fields = Mv.Constants.member_fields() domain_map = Enum.reduce(domain_fields, %{}, fn field, acc -> field_string = Atom.to_string(field) default_visibility = if field == :exit_date, do: false, else: true show_in_overview = Map.get(visibility_config, field, default_visibility) Map.put(acc, field_string, show_in_overview) end) Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc -> Map.put(acc, Atom.to_string(field), true) end) end # Gets custom field visibility (all custom fields with show_in_overview=true are visible) defp get_custom_field_visibility(custom_fields) do prefix = Mv.Constants.custom_field_prefix() Enum.reduce(custom_fields, %{}, fn custom_field, acc -> field_string = "#{prefix}#{custom_field.id}" visible = Map.get(custom_field, :show_in_overview, true) Map.put(acc, field_string, visible) end) end # Converts field string to atom (for member fields) or keeps as string (for custom fields). # Maps export-only alias to canonical UI key so only one option controls the column. defp to_field_identifier(field_string) when is_binary(field_string) do if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do field_string else atom = try do String.to_existing_atom(field_string) rescue ArgumentError -> field_string end if atom == @export_only_alias, do: :membership_fee_status, else: atom end end # Converts field identifier to string defp field_to_string(field) when is_atom(field), do: Atom.to_string(field) defp field_to_string(field) when is_binary(field), do: field end