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. # Groups and membership_fee_type are also pseudo fields (not in member_fields(), displayed in the table). # :name and :address are composite display-only columns (Name = name + email, # Address = street/house number over postal code/city). # Order mirrors the overview table / export column order (fee type before fee # status), so the Columns dropdown and the table agree. @pseudo_member_fields [:membership_fee_type, :membership_fee_status, :groups, :name, :address] # The composite display columns and the constituent columns they replace. The # column manager offers exactly one variant per group, selected by the view # settings: the composite when the matching "compact" setting is on, otherwise # the constituents (see `offered_member_fields/2`). @name_composite :name @name_constituents [:first_name, :last_name, :email] @address_composite :address @address_constituents [:street, :house_number, :postal_code, :city] # Export/API may accept this as alias; must not appear in the UI options list. @export_only_alias :payment_status # Curated default-visible column set (§1.2): the columns shown on first visit # when there is no persisted selection and no global override. Everything else # (the individual name/address sub-fields, notes, dates other than join date) # defaults to hidden so the table fits common desktop widths. @default_visible_fields MapSet.new([ :name, :address, :membership_fee_type, :membership_fee_status, :groups, :join_date ]) # The curated default-visible column set for the given view settings. In # compact mode the composite `:name` / `:address` columns are default; when a # composite is switched off, its constituent columns take over as the defaults # instead (§7b). While the Member composite is compact, `include_email` decides # where the email lives: folded into the Member cell (`true`, no separate # column) or as a default-visible `:email` column (`false`). defp default_visible_set(compact_member, include_email, compact_address) do @default_visible_fields |> apply_name_default(compact_member, include_email) |> apply_group_default(compact_address, @address_composite, @address_constituents) end # Compact Member cell with email folded in: only the composite is default. defp apply_name_default(set, true = _compact, true = _include_email), do: set # Compact Member cell without folded email: the composite plus a separate # default-visible email column. defp apply_name_default(set, true = _compact, false = _include_email), do: MapSet.put(set, :email) # Non-compact: drop the composite and make first/last name and email default. defp apply_name_default(set, false = _compact, _include_email) do set |> MapSet.delete(@name_composite) |> MapSet.union(MapSet.new(@name_constituents)) end # Compact: the composite stays the default. Non-compact: drop the composite and # make the constituents default-visible. defp apply_group_default(set, true, _composite, _constituents), do: set defp apply_group_default(set, false, composite, constituents) do set |> MapSet.delete(composite) |> MapSet.union(MapSet.new(constituents)) end @doc """ Member-field atoms the column manager offers for the given view settings, in dropdown/table order. The identity block always leads, regardless of whether each group is compact: 1. the name group — the composite `:name` when compact, otherwise its constituents `:first_name`, `:last_name`; 2. the `:email` column, when offered as a separate column (it is folded into the Member cell only while the composite is compact and `include_email` is on); 3. the address group — the composite `:address` when compact, otherwise its constituents `:street`, `:house_number`, `:postal_code`, `:city`. Everything else follows in the configured data-fields order. This keeps the name group ahead of the address group even when the name group is expanded into `:first_name`/`:last_name` while the address stays compact. """ @spec offered_member_fields(boolean(), boolean(), boolean()) :: [atom()] def offered_member_fields(compact_member, include_email, compact_address) do name_group = if compact_member, do: [@name_composite], else: [:first_name, :last_name] email_group = if email_offered?(compact_member, include_email), do: [:email], else: [] address_group = if compact_address, do: [@address_composite], else: @address_constituents leading = name_group ++ email_group ++ address_group rest = Enum.reject(overview_member_fields(), fn field -> field == @export_only_alias or hidden_variant?(field, compact_member, include_email, compact_address) or field in leading end) leading ++ rest end @doc """ The DB constituent columns the composite `:name` cell stands in for, in canonical order. `:email` is a constituent only while it is folded into the compact Member cell (`include_email?` true); otherwise it is its own column. """ @spec name_constituents(boolean()) :: [atom()] def name_constituents(include_email?) do if include_email?, do: @name_constituents, else: @name_constituents -- [:email] end @doc "The DB constituent columns the composite `:address` cell stands in for, in canonical order." @spec address_constituents() :: [:street | :house_number | :postal_code | :city, ...] def address_constituents, do: @address_constituents # The email is a separate offered column except when it is folded into the # compact Member cell (compact + include_email). defp email_offered?(compact_member, include_email), do: not (compact_member and include_email) # The variant of a composite group that is not offered in the current mode. defp hidden_variant?(field, compact_member, include_email, compact_address) do name_variant_hidden?(field, compact_member, include_email) or address_variant_hidden?(field, compact_address) end # Compact hides first-name/last-name (folded into the composite). The email is # hidden only when it is folded into the cell (compact + include_email); with # include_email off it stays offered as a separate column. Non-compact hides the # composite in favour of the constituents. defp name_variant_hidden?(@name_composite, compact_member, _include_email), do: not compact_member defp name_variant_hidden?(:email, compact_member, include_email), do: compact_member and include_email defp name_variant_hidden?(field, compact_member, _include_email) when field in [:first_name, :last_name], do: compact_member defp name_variant_hidden?(_field, _compact_member, _include_email), do: false defp address_variant_hidden?(@address_composite, compact_address), do: not compact_address defp address_variant_hidden?(field, compact_address) when field in [:street, :house_number, :postal_code, :city], do: compact_address defp address_variant_hidden?(_field, _compact_address), do: false @doc """ All fields the column manager offers for the given view settings: the offered member fields (see `offered_member_fields/3`) followed by the custom fields. """ @spec get_offered_fields([struct()], boolean(), boolean(), boolean()) :: [ atom() | String.t() ] def get_offered_fields(custom_fields, compact_member, include_email, compact_address) do custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}") offered_member_fields(compact_member, include_email, compact_address) ++ custom_field_names end defp overview_member_fields do Mv.Constants.member_fields() ++ @pseudo_member_fields end @doc """ Builds field selection from URL, scoped to the columns offered for the given view settings (`:compact_member` / `:member_include_email` / `:compact_address` in `opts`): fields in `url_selection` are visible, all others false. Use when `?fields=...` is in the URL so column visibility is not merged with global settings. """ @spec selection_from_url_only(%{String.t() => boolean()}, [struct()], keyword()) :: %{ String.t() => boolean() } def selection_from_url_only(url_selection, custom_fields, opts) when is_map(url_selection) and is_list(opts) do {cm, ie, ca} = view_opts(opts) do_selection_from_url_only(url_selection, get_offered_fields(custom_fields, cm, ie, ca)) end def selection_from_url_only(_, _, _), do: %{} defp do_selection_from_url_only(url_selection, all_fields) do Enum.reduce(all_fields, %{}, fn field, acc -> field_string = field_to_string(field) visible = Map.get(url_selection, field_string, false) Map.put(acc, field_string, visible) end) end defp view_opts(opts) do { Keyword.get(opts, :compact_member, true), Keyword.get(opts, :member_include_email, false), Keyword.get(opts, :compact_address, true) } end @doc """ Merges user field selection with global settings, scoped to the columns offered for the given view settings (`:compact_member` / `:member_include_email` / `:compact_address` in `opts`). 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 visible. Only offered columns appear in the result, so a column that is not applicable in the current mode (e.g. `:first_name` while the composite "Name" is on) never leaks into the visible set. Defaults follow the mode via `default_visible_set/3`. """ @spec merge_with_global_settings(%{String.t() => boolean()}, map(), [struct()], keyword()) :: %{String.t() => boolean()} def merge_with_global_settings(user_selection, global_settings, custom_fields, opts) when is_list(opts) do {cm, ie, ca} = view_opts(opts) all_fields = get_offered_fields(custom_fields, cm, ie, ca) default_set = default_visible_set(cm, ie, ca) do_merge(user_selection, global_settings, custom_fields, all_fields, default_set) end defp do_merge(user_selection, global_settings, custom_fields, all_fields, default_set) do global_visibility = get_global_visibility_map(global_settings, custom_fields, default_set) 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 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() :: [:membership_fee_status | :membership_fee_type | :groups, ...] 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([:membership_fee_status]) 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, default_set) do member_visibility = get_member_field_visibility_from_settings(settings, default_set) 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, default_set) 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 = MapSet.member?(default_set, field) 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), MapSet.member?(default_set, field)) 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