diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index b8fe0fc..4f6bf37 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -111,6 +111,126 @@ defmodule MvWeb.CoreComponents do end end + @doc """ + Renders a dropdown menu. + + ## Examples + + <.dropdown_menu items={@items} open={@open} phx-target={@myself} /> + """ + attr :id, :string, default: "dropdown-menu" + attr :items, :list, required: true, doc: "List of %{label: string, value: any} maps" + attr :button_label, :string, default: "Dropdown" + attr :icon, :string, default: nil + attr :checkboxes, :boolean, default: false + attr :selected, :map, default: %{} + attr :open, :boolean, default: false, doc: "Whether the dropdown is open" + attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons" + attr :phx_target, :any, default: nil + + def dropdown_menu(assigns) do + unless Map.has_key?(assigns, :phx_target) do + raise ArgumentError, ":phx_target is required in dropdown_menu/1" + end + + assigns = + assign_new(assigns, :items, fn -> [] end) + |> assign_new(:button_label, fn -> "Dropdown" end) + |> assign_new(:icon, fn -> nil end) + |> assign_new(:checkboxes, fn -> false end) + |> assign_new(:selected, fn -> %{} end) + |> assign_new(:open, fn -> false end) + |> assign_new(:show_select_buttons, fn -> false end) + |> assign(:phx_target, assigns.phx_target) + |> assign_new(:id, fn -> "dropdown-menu" end) + + ~H""" +
+ + + +
+ """ + end + @doc """ Renders an input with label and error messages. diff --git a/lib/mv_web/components/field_visibility_dropdown_component.ex b/lib/mv_web/components/field_visibility_dropdown_component.ex new file mode 100644 index 0000000..1ee0487 --- /dev/null +++ b/lib/mv_web/components/field_visibility_dropdown_component.ex @@ -0,0 +1,172 @@ +defmodule MvWeb.Components.FieldVisibilityDropdownComponent do + @moduledoc """ + LiveComponent for managing field visibility in the member overview. + + Provides an accessible dropdown menu where users can select/deselect + which member fields and custom fields are visible in the table. + + ## Props + - `:all_fields` - List of all available fields + - `:custom_fields` - List of CustomField resources + - `:selected_fields` - Map field_name → boolean + - `:id` - Component ID + + ## Events sent to parent: + - `{:field_toggled, field, value}` + - `{:fields_selected, map}` + """ + + use MvWeb, :live_component + + # --------------------------------------------------------------------------- + # UPDATE + # --------------------------------------------------------------------------- + + @impl true + def update(assigns, socket) do + socket = + socket + |> assign(assigns) + |> assign_new(:open, fn -> false end) + |> assign_new(:all_fields, fn -> [] end) + |> assign_new(:custom_fields, fn -> [] end) + |> assign_new(:selected_fields, fn -> %{} end) + + {:ok, socket} + end + + # --------------------------------------------------------------------------- + # RENDER + # --------------------------------------------------------------------------- + + @impl true + def render(assigns) do + all_fields = assigns.all_fields || [] + custom_fields = assigns.custom_fields || [] + + all_items = + Enum.map(member_fields(all_fields), fn field -> + %{ + value: field_to_string(field), + label: format_field_label(field) + } + end) ++ + Enum.map(custom_fields(all_fields), fn field -> + %{ + value: field, + label: format_custom_field_label(field, custom_fields) + } + end) + + assigns = assign(assigns, :all_items, all_items) + + # LiveComponents require a static HTML element as root, not a function component + ~H""" +
+ <.dropdown_menu + id="field-visibility-menu" + icon="hero-adjustments-horizontal" + button_label={gettext("Columns")} + items={@all_items} + checkboxes={true} + selected={@selected_fields} + open={@open} + show_select_buttons={true} + phx_target={@myself} + /> +
+ """ + end + + # --------------------------------------------------------------------------- + # EVENTS (matching the Core Component API) + # --------------------------------------------------------------------------- + + @impl true + def handle_event("toggle_dropdown", _params, socket) do + {:noreply, assign(socket, :open, !socket.assigns.open)} + end + + def handle_event("close_dropdown", _params, socket) do + {:noreply, assign(socket, :open, false)} + end + + # toggle single item + def handle_event("select_item", %{"item" => item}, socket) do + current = Map.get(socket.assigns.selected_fields, item, true) + updated = Map.put(socket.assigns.selected_fields, item, !current) + + send(self(), {:field_toggled, item, !current}) + {:noreply, assign(socket, :selected_fields, updated)} + end + + # select all + def handle_event("select_all", _params, socket) do + all = + socket.assigns.all_fields + |> Enum.map(&field_to_string/1) + |> Enum.map(&{&1, true}) + |> Enum.into(%{}) + + send(self(), {:fields_selected, all}) + {:noreply, assign(socket, :selected_fields, all)} + end + + # select none + def handle_event("select_none", _params, socket) do + none = + socket.assigns.all_fields + |> Enum.map(&field_to_string/1) + |> Enum.map(&{&1, false}) + |> Enum.into(%{}) + + send(self(), {:fields_selected, none}) + {:noreply, assign(socket, :selected_fields, none)} + end + + # --------------------------------------------------------------------------- + # HELPERS (with defensive nil guards) + # --------------------------------------------------------------------------- + + defp member_fields(nil), do: [] + + defp member_fields(fields) do + Enum.filter(fields, fn field -> + is_atom(field) || + (is_binary(field) && not String.starts_with?(field, "custom_field_")) + end) + end + + defp custom_fields(nil), do: [] + + defp custom_fields(fields) do + Enum.filter(fields, fn field -> + is_binary(field) && String.starts_with?(field, "custom_field_") + end) + end + + 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 + + defp format_field_label(field) do + field + |> field_to_string() + |> String.replace("_", " ") + |> String.split() + |> Enum.map(&String.capitalize/1) + |> Enum.join(" ") + end + + defp format_custom_field_label(field_string, custom_fields) do + case String.trim_leading(field_string, "custom_field_") do + "" -> + field_string + + id -> + case Enum.find(custom_fields, fn cf -> to_string(cf.id) == id end) do + nil -> gettext("Custom Field %{id}", id: id) + custom_field -> custom_field.name + end + end + end +end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 6bce495..522dfa1 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -31,6 +31,8 @@ defmodule MvWeb.MemberLive.Index do alias Mv.Membership alias MvWeb.MemberLive.Index.Formatter + alias MvWeb.MemberLive.Index.FieldSelection + alias MvWeb.MemberLive.Index.FieldVisibility # Prefix used in sort field names for custom fields (e.g., "custom_field_") @custom_field_prefix "custom_field_" @@ -48,8 +50,8 @@ defmodule MvWeb.MemberLive.Index do and member selection. Actual data loading happens in `handle_params/3`. """ @impl true - def mount(_params, _session, socket) do - # Load custom fields that should be shown in overview + def mount(_params, session, socket) do + # Load custom fields that should be shown in overview (for display) # Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView # and result in a 500 error page. This is appropriate for LiveViews where errors # should be visible to the user rather than silently failing. @@ -59,6 +61,12 @@ defmodule MvWeb.MemberLive.Index do |> Ash.Query.sort(name: :asc) |> Ash.read!() + # Load ALL custom fields for the dropdown (to show all available fields) + all_custom_fields = + Mv.Membership.CustomField + |> Ash.Query.sort(name: :asc) + |> Ash.read!() + # Load settings once to avoid N+1 queries settings = case Membership.get_settings() do @@ -67,6 +75,20 @@ defmodule MvWeb.MemberLive.Index do {:error, _} -> %{member_field_visibility: %{}} end + # Load user field selection from session + session_selection = FieldSelection.get_from_session(session) + + # Get all available fields (for dropdown - includes ALL custom fields) + all_available_fields = FieldVisibility.get_all_available_fields(all_custom_fields) + + # Merge session selection with global settings for initial state (use all_custom_fields) + initial_selection = + FieldVisibility.merge_with_global_settings( + session_selection, + settings, + all_custom_fields + ) + socket = socket |> assign(:page_title, gettext("Members")) @@ -76,8 +98,14 @@ defmodule MvWeb.MemberLive.Index do |> assign(:selected_members, []) |> assign(:settings, settings) |> assign(:custom_fields_visible, custom_fields_visible) + |> assign(:all_custom_fields, all_custom_fields) + |> assign(:all_available_fields, all_available_fields) + |> assign(:user_field_selection, initial_selection) |> assign(:member_field_configurations, get_member_field_configurations(settings)) - |> assign(:member_fields_visible, get_visible_member_fields(settings)) + |> assign( + :member_fields_visible, + FieldVisibility.get_visible_member_fields(initial_selection) + ) # We call handle params to use the query from the URL {:ok, socket} @@ -144,6 +172,8 @@ defmodule MvWeb.MemberLive.Index do ## Supported messages: - `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL - `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL + - `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent + - `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent """ @impl true def handle_info({:sort, field_str}, socket) do @@ -170,11 +200,12 @@ defmodule MvWeb.MemberLive.Index do existing_sort_query = socket.assigns.sort_order # Build the URL with queries - query_params = %{ - "query" => q, - "sort_field" => existing_field_query, - "sort_order" => existing_sort_query - } + query_params = + build_query_params(socket, %{ + "query" => q, + "sort_field" => existing_field_query, + "sort_order" => existing_sort_query + }) # Set the new path with params new_path = ~p"/members?#{query_params}" @@ -187,22 +218,109 @@ defmodule MvWeb.MemberLive.Index do )} end + @impl true + def handle_info({:field_toggled, field_string, visible}, socket) do + # Update user field selection + new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible) + + # Save to session (cookie will be saved on next page load via handle_params) + socket = update_session_field_selection(socket, new_selection) + + # Merge with global settings + final_selection = + FieldVisibility.merge_with_global_settings( + new_selection, + socket.assigns.settings, + socket.assigns.custom_fields_visible + ) + + # Get visible fields + visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection) + visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) + + socket = + socket + |> assign(:user_field_selection, final_selection) + |> assign(:member_fields_visible, visible_member_fields) + |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) + |> load_members(socket.assigns.query) + |> prepare_dynamic_cols() + |> push_field_selection_url() + + {:noreply, socket} + end + + @impl true + def handle_info({:fields_selected, selection}, socket) do + # Save to session + socket = update_session_field_selection(socket, selection) + + # Merge with global settings (use all_custom_fields for merging) + final_selection = + FieldVisibility.merge_with_global_settings( + selection, + socket.assigns.settings, + socket.assigns.all_custom_fields + ) + + # Get visible fields + visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection) + visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) + + socket = + socket + |> assign(:user_field_selection, final_selection) + |> assign(:member_fields_visible, visible_member_fields) + |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) + |> load_members(socket.assigns.query) + |> prepare_dynamic_cols() + |> push_field_selection_url() + + {:noreply, socket} + end + # ----------------------------------------------------------------- # Handle Params from the URL # ----------------------------------------------------------------- @doc """ Handles URL parameter changes. - Parses query parameters for search query, sort field, and sort order, + Parses query parameters for search query, sort field, sort order, and field selection, then loads members accordingly. This enables bookmarkable URLs and browser back/forward navigation. """ @impl true def handle_params(params, _url, socket) do + # Parse field selection from URL + url_selection = FieldSelection.parse_from_url(params) + + # Merge with session selection (URL has priority) + merged_selection = + FieldSelection.merge_sources( + url_selection, + socket.assigns.user_field_selection, + %{} + ) + + # Merge with global settings (use all_custom_fields for merging) + final_selection = + FieldVisibility.merge_with_global_settings( + merged_selection, + socket.assigns.settings, + socket.assigns.all_custom_fields + ) + + # Get visible fields + visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection) + visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) + socket = socket |> maybe_update_search(params) |> maybe_update_sort(params) + |> assign(:user_field_selection, final_selection) + |> assign(:member_fields_visible, visible_member_fields) + |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) |> load_members(params["query"]) |> prepare_dynamic_cols() @@ -215,10 +333,16 @@ defmodule MvWeb.MemberLive.Index do # - `:custom_field` - The CustomField resource # - `:render` - A function that formats the custom field value for a given member # + # Only includes custom fields that are visible according to user field selection. + # # Returns the socket with `:dynamic_cols` assigned. defp prepare_dynamic_cols(socket) do + visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] + dynamic_cols = - Enum.map(socket.assigns.custom_fields_visible, fn custom_field -> + socket.assigns.custom_fields_visible + |> Enum.filter(fn custom_field -> custom_field.id in visible_custom_field_ids end) + |> Enum.map(fn custom_field -> %{ custom_field: custom_field, render: fn member -> @@ -294,11 +418,11 @@ defmodule MvWeb.MemberLive.Index do field end - query_params = %{ - "query" => socket.assigns.query, - "sort_field" => field_str, - "sort_order" => Atom.to_string(order) - } + query_params = + build_query_params(socket, %{ + "sort_field" => field_str, + "sort_order" => Atom.to_string(order) + }) new_path = ~p"/members?#{query_params}" @@ -309,6 +433,47 @@ defmodule MvWeb.MemberLive.Index do )} end + # Builds query parameters including field selection + defp build_query_params(socket, base_params) do + base_params + |> Map.put("query", socket.assigns.query || "") + |> maybe_add_field_selection(socket.assigns[:user_field_selection]) + end + + # Adds field selection to query params if present + defp maybe_add_field_selection(params, nil), do: params + + defp maybe_add_field_selection(params, selection) when is_map(selection) do + fields_param = FieldSelection.to_url_param(selection) + if fields_param != "", do: Map.put(params, "fields", fields_param), else: params + end + + defp maybe_add_field_selection(params, _), do: params + + # Pushes URL with updated field selection + defp push_field_selection_url(socket) do + query_params = + build_query_params(socket, %{ + "sort_field" => field_to_string(socket.assigns.sort_field), + "sort_order" => Atom.to_string(socket.assigns.sort_order) + }) + + new_path = ~p"/members?#{query_params}" + + push_patch(socket, to: new_path, replace: true) + end + + # Converts field 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 + + # Updates session field selection (stored in socket for now, actual session update via controller) + defp update_session_field_selection(socket, selection) do + # Store in socket for now - actual session persistence would require a controller + # This is a placeholder for future session persistence + assign(socket, :user_field_selection, selection) + end + # Loads members from the database with custom field values and applies search/sort filters. # # Process: @@ -333,9 +498,9 @@ defmodule MvWeb.MemberLive.Index do |> Ash.Query.new() |> 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) - query = load_custom_field_values(query, custom_field_ids_list) + # Load custom field values for visible custom fields (based on user selection) + visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] + query = load_custom_field_values(query, visible_custom_field_ids) # Apply the search filter first query = apply_search_filter(query, search_query) @@ -770,20 +935,6 @@ defmodule MvWeb.MemberLive.Index do end) end - # Gets the list of member fields that should be visible in the overview. - # - # Filters the member field configurations to return only fields with show_in_overview: true. - # - # Parameters: - # - `settings` - The settings struct loaded from the database - # - # Returns a list of atoms representing visible member field names. - @spec get_visible_member_fields(map()) :: [atom()] - defp get_visible_member_fields(settings) do - get_member_field_configurations(settings) - |> Enum.filter(fn {_field, show_in_overview} -> show_in_overview end) - |> Enum.map(fn {field, _show_in_overview} -> field end) - end # Normalizes visibility config map keys from strings to atoms. # JSONB in PostgreSQL converts atom keys to string keys when storing. @@ -808,4 +959,16 @@ defmodule MvWeb.MemberLive.Index do end defp normalize_visibility_config(_), do: %{} + + # Extracts custom field IDs from visible custom field strings + # Format: "custom_field_" -> + defp extract_custom_field_ids(visible_custom_fields) do + Enum.map(visible_custom_fields, fn field_string -> + case String.split(field_string, "custom_field_") do + ["", id] -> id + _ -> nil + end + end) + |> Enum.filter(&(&1 != nil)) + 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 594f2d8..e6076aa 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -2,6 +2,13 @@ <.header> {gettext("Members")} <:actions> + <.live_component + module={MvWeb.Components.FieldVisibilityDropdownComponent} + id="field-visibility-dropdown" + all_fields={@all_available_fields} + custom_fields={@all_custom_fields} + selected_fields={@user_field_selection} + /> <.button variant="primary" navigate={~p"/members/new"}> <.icon name="hero-plus" /> {gettext("New Member")} @@ -54,6 +61,7 @@ <:col :let={member} + :if={:first_name in @member_fields_visible} label={ ~H""" <.live_component @@ -67,7 +75,25 @@ """ } > - {member.first_name} {member.last_name} + {member.first_name} + + <:col + :let={member} + :if={:last_name in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_last_name} + field={:last_name} + label={gettext("Last name")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.last_name} <:col :let={member} diff --git a/lib/mv_web/live/member_live/index/field_selection.ex b/lib/mv_web/live/member_live/index/field_selection.ex new file mode 100644 index 0000000..4b065f0 --- /dev/null +++ b/lib/mv_web/live/member_live/index/field_selection.ex @@ -0,0 +1,232 @@ +defmodule MvWeb.MemberLive.Index.FieldSelection do + @moduledoc """ + Handles user-specific field selection persistence and URL parameter parsing. + + This module manages: + - Reading/writing field selection from cookies (persistent storage) + - Reading/writing field selection from session (temporary storage) + - Parsing field selection from URL parameters + - Merging multiple sources with priority: URL > Session > Cookie + + ## Data Format + + Field selection is stored as a map: + ```elixir + %{ + "first_name" => true, + "email" => true, + "street" => false, + "custom_field_abc-123" => true + } + ``` + + ## Cookie/Session Format + + Stored as JSON string: `{"first_name":true,"email":true}` + + ## URL Format + + Comma-separated list: `?fields=first_name,email,custom_field_abc-123` + """ + + @cookie_name "member_field_selection" + @cookie_max_age 365 * 24 * 60 * 60 + @session_key "member_field_selection" + + @doc """ + Reads field selection from session. + + Returns a map of field names (strings) to boolean visibility values. + Returns empty map if no selection is stored. + """ + @spec get_from_session(map()) :: %{String.t() => boolean()} + def get_from_session(session) when is_map(session) do + case Map.get(session, @session_key) do + nil -> %{} + json_string when is_binary(json_string) -> parse_json(json_string) + _ -> %{} + end + end + + def get_from_session(_), do: %{} + + @doc """ + Saves field selection to session. + + Converts the map to JSON string and stores it in the session. + """ + @spec save_to_session(map(), %{String.t() => boolean()}) :: map() + def save_to_session(session, selection) when is_map(selection) do + json_string = Jason.encode!(selection) + Map.put(session, @session_key, json_string) + end + + def save_to_session(session, _), do: session + + @doc """ + Reads field selection from cookie. + + Returns a map of field names (strings) to boolean visibility values. + Returns empty map if no cookie is present. + + Note: This function requires the connection to have cookies parsed. + In LiveView, cookies are typically accessed via get_connect_info. + """ + @spec get_from_cookie(Plug.Conn.t()) :: %{String.t() => boolean()} + def get_from_cookie(conn) do + case Plug.Conn.get_req_header(conn, "cookie") do + nil -> + %{} + + cookie_header -> + # Parse cookies manually from header + cookies = parse_cookie_header(cookie_header) + + case Map.get(cookies, @cookie_name) do + nil -> %{} + json_string when is_binary(json_string) -> parse_json(json_string) + _ -> %{} + end + end + end + + # Parses cookie header string into a map + defp parse_cookie_header(cookie_header) when is_binary(cookie_header) do + cookie_header + |> String.split(";") + |> Enum.map(&String.trim/1) + |> Enum.map(&String.split(&1, "=", parts: 2)) + |> Enum.reduce(%{}, fn + [key, value], acc -> Map.put(acc, key, URI.decode(value)) + [key], acc -> Map.put(acc, key, "") + _, acc -> acc + end) + end + + defp parse_cookie_header(_), do: %{} + + @doc """ + Saves field selection to cookie. + + Sets a persistent cookie with the field selection as JSON. + """ + @spec save_to_cookie(Plug.Conn.t(), %{String.t() => boolean()}) :: Plug.Conn.t() + def save_to_cookie(conn, selection) when is_map(selection) do + json_string = Jason.encode!(selection) + secure = Application.get_env(:mv, :use_secure_cookies, false) + + Plug.Conn.put_resp_cookie(conn, @cookie_name, json_string, + max_age: @cookie_max_age, + same_site: "Lax", + http_only: true, + secure: secure + ) + end + + def save_to_cookie(conn, _), do: conn + + @doc """ + Parses field selection from URL parameters. + + Expects a comma-separated list of field names in the `fields` parameter. + All fields in the list are set to `true` (visible). + + ## Examples + + iex> parse_from_url(%{"fields" => "first_name,email"}) + %{"first_name" => true, "email" => true} + + iex> parse_from_url(%{"fields" => "custom_field_abc-123"}) + %{"custom_field_abc-123" => true} + + iex> parse_from_url(%{}) + %{} + """ + @spec parse_from_url(map()) :: %{String.t() => boolean()} + def parse_from_url(params) when is_map(params) do + case Map.get(params, "fields") do + nil -> %{} + "" -> %{} + fields_string when is_binary(fields_string) -> parse_fields_string(fields_string) + _ -> %{} + end + end + + def parse_from_url(_), do: %{} + + @doc """ + Merges multiple field selection sources with priority. + + Priority order (highest to lowest): + 1. URL parameters + 2. Session + 3. Cookie + + Later sources override earlier ones for the same field. + + ## Examples + + iex> merge_sources(%{"first_name" => true}, %{"email" => true}, %{"street" => true}) + %{"first_name" => true, "email" => true, "street" => true} + + iex> merge_sources(%{"first_name" => false}, %{"first_name" => true}, %{}) + %{"first_name" => false} # URL has priority + """ + @spec merge_sources( + %{String.t() => boolean()}, + %{String.t() => boolean()}, + %{String.t() => boolean()} + ) :: %{String.t() => boolean()} + def merge_sources(url_selection, session_selection, cookie_selection) do + %{} + |> Map.merge(cookie_selection) + |> Map.merge(session_selection) + |> Map.merge(url_selection) + end + + @doc """ + Converts field selection map to URL parameter string. + + Returns a comma-separated string of visible fields (where value is `true`). + + ## Examples + + iex> to_url_param(%{"first_name" => true, "email" => true, "street" => false}) + "first_name,email" + """ + @spec to_url_param(%{String.t() => boolean()}) :: String.t() + def to_url_param(selection) when is_map(selection) do + selection + |> Enum.filter(fn {_field, visible} -> visible end) + |> Enum.map(fn {field, _visible} -> field end) + |> Enum.join(",") + end + + def to_url_param(_), do: "" + + # Parses a JSON string into a map, handling errors gracefully + defp parse_json(json_string) when is_binary(json_string) do + case Jason.decode(json_string) do + {:ok, decoded} when is_map(decoded) -> + # Ensure all values are booleans + Enum.reduce(decoded, %{}, fn + {key, value} when is_boolean(value) -> {key, value} + {key, _value} -> {key, true} + end) + + _ -> + %{} + end + end + + defp parse_json(_), do: %{} + + # Parses a comma-separated string of field names + defp parse_fields_string(fields_string) do + fields_string + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.filter(&(&1 != "")) + |> Enum.reduce(%{}, fn field, acc -> Map.put(acc, field, true) end) + end +end diff --git a/lib/mv_web/live/member_live/index/field_visibility.ex b/lib/mv_web/live/member_live/index/field_visibility.ex new file mode 100644 index 0000000..8dd36fc --- /dev/null +++ b/lib/mv_web/live/member_live/index/field_visibility.ex @@ -0,0 +1,235 @@ +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) + """ + + @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 = Mv.Constants.member_fields() + 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) + 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 = Mv.Constants.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) + end + + def get_visible_member_fields(_), 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 + field_selection + |> Enum.filter(fn {field_string, visible} -> + visible && String.starts_with?(field_string, "custom_field_") + 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 + defp get_member_field_visibility_from_settings(settings) do + visibility_config = + normalize_visibility_config(Map.get(settings, :member_field_visibility, %{})) + + member_fields = Mv.Constants.member_fields() + + Enum.reduce(member_fields, %{}, fn field, acc -> + field_string = Atom.to_string(field) + show_in_overview = Map.get(visibility_config, field, true) + Map.put(acc, field_string, show_in_overview) + end) + end + + # Gets custom field visibility (all custom fields with show_in_overview=true are visible) + defp get_custom_field_visibility(custom_fields) do + Enum.reduce(custom_fields, %{}, fn custom_field, acc -> + field_string = "custom_field_#{custom_field.id}" + visible = Map.get(custom_field, :show_in_overview, true) + Map.put(acc, field_string, visible) + end) + end + + # Normalizes visibility config map keys from strings to atoms + defp normalize_visibility_config(config) when is_map(config) do + Enum.reduce(config, %{}, fn + {key, value}, acc when is_atom(key) -> + Map.put(acc, key, value) + + {key, value}, acc when is_binary(key) -> + try do + atom_key = String.to_existing_atom(key) + Map.put(acc, atom_key, value) + rescue + ArgumentError -> acc + end + + _, acc -> + acc + end) + end + + defp normalize_visibility_config(_), do: %{} + + # Converts field string to atom (for member fields) or keeps as string (for custom fields) + defp to_field_identifier(field_string) when is_binary(field_string) do + if String.starts_with?(field_string, "custom_field_") do + field_string + else + try do + String.to_existing_atom(field_string) + rescue + ArgumentError -> field_string + end + 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