diff --git a/Justfile b/Justfile index b835cf4..2231525 100644 --- a/Justfile +++ b/Justfile @@ -1,7 +1,4 @@ set dotenv-load := true -set export := true - -MIX_QUIET := "1" run: install-dependencies start-database migrate-database seed-database mix phx.server diff --git a/lib/membership/member.ex b/lib/membership/member.ex index b788dc9..8d271d7 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -401,70 +401,6 @@ defmodule Mv.Membership.Member do identity :unique_email, [:email] end - @doc """ - Checks if a member field should be shown in the overview. - - Reads the visibility configuration from Settings resource. If a field is not - configured in settings, it defaults to `true` (visible). - - ## Parameters - - `field` - Atom representing the member field name (e.g., `:email`, `:street`) - - ## Returns - - `true` if the field should be shown in overview (default) - - `false` if the field is configured as hidden in settings - - ## Examples - - iex> Member.show_in_overview?(:email) - true - - iex> Member.show_in_overview?(:street) - true # or false if configured in settings - - """ - @spec show_in_overview?(atom()) :: boolean() - def show_in_overview?(field) when is_atom(field) do - case Mv.Membership.get_settings() do - {:ok, settings} -> - visibility_config = settings.member_field_visibility || %{} - # Normalize map keys to atoms (JSONB may return string keys) - normalized_config = normalize_visibility_config(visibility_config) - - # Get value from normalized config, default to true - Map.get(normalized_config, field, true) - - {:error, _} -> - # If settings can't be loaded, default to visible - true - end - end - - def show_in_overview?(_), do: true - - # Normalizes visibility config map keys from strings to atoms. - # JSONB in PostgreSQL converts atom keys to string keys when storing. - 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: %{} - @doc """ Performs fuzzy search on members using PostgreSQL trigram similarity. diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 7bfb07b..334bcc1 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -18,17 +18,5 @@ defmodule Mv.Constants do :postal_code ] - @custom_field_prefix "custom_field_" - def member_fields, do: @member_fields - - @doc """ - Returns the prefix used for custom field keys in field visibility maps. - - ## Examples - - iex> Mv.Constants.custom_field_prefix() - "custom_field_" - """ - def custom_field_prefix, do: @custom_field_prefix end diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index be64655..08133b5 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -119,123 +119,6 @@ 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, required: true, doc: "The LiveView/LiveComponent target for events" - - def dropdown_menu(assigns) do - ~H""" -
- - - -
- """ - end - @doc """ Renders an input with label and error messages. diff --git a/lib/mv_web/live/components/field_visibility_dropdown_component.ex b/lib/mv_web/live/components/field_visibility_dropdown_component.ex deleted file mode 100644 index 642273c..0000000 --- a/lib/mv_web/live/components/field_visibility_dropdown_component.ex +++ /dev/null @@ -1,176 +0,0 @@ -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(extract_member_field_keys(all_fields), fn field -> - %{ - value: field_to_string(field), - label: format_field_label(field) - } - end) ++ - Enum.map(extract_custom_field_keys(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 extract_member_field_keys(nil), do: [] - - defp extract_member_field_keys(fields) do - prefix = Mv.Constants.custom_field_prefix() - - Enum.filter(fields, fn field -> - is_atom(field) || - (is_binary(field) && not String.starts_with?(field, prefix)) - end) - end - - defp extract_custom_field_keys(nil), do: [] - - defp extract_custom_field_keys(fields) do - prefix = Mv.Constants.custom_field_prefix() - - Enum.filter(fields, fn field -> - is_binary(field) && String.starts_with?(field, prefix) - 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_join(" ", &String.capitalize/1) - end - - defp format_custom_field_label(field_string, custom_fields) do - id = String.trim_leading(field_string, Mv.Constants.custom_field_prefix()) - find_custom_field_name(id, field_string, custom_fields) - end - - defp find_custom_field_name("", field_string, _custom_fields), do: field_string - - defp find_custom_field_name(id, _field_string, custom_fields) do - 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 diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index ad4a4a9..792466c 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -33,11 +33,9 @@ defmodule MvWeb.MemberLive.Index do alias Mv.Membership alias MvWeb.MemberLive.Index.Formatter alias MvWeb.Helpers.DateFormatter - 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 Mv.Constants.custom_field_prefix() + @custom_field_prefix "custom_field_" # Member fields that are loaded for the overview # Uses constants from Mv.Constants to ensure consistency @@ -52,8 +50,8 @@ defmodule MvWeb.MemberLive.Index do payment filter, 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 (for display) + def mount(_params, _session, socket) do + # Load custom fields that should be shown in overview # 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. @@ -63,12 +61,6 @@ 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 @@ -77,20 +69,6 @@ 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")) @@ -99,15 +77,8 @@ defmodule MvWeb.MemberLive.Index do |> assign_new(:sort_order, fn -> :asc end) |> assign(:paid_filter, nil) |> assign(:selected_members, MapSet.new()) - |> 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_fields_visible, - FieldVisibility.get_visible_member_fields(initial_selection) - ) + |> assign(:member_fields_visible, get_visible_member_fields(settings)) # We call handle params to use the query from the URL {:ok, socket} @@ -212,8 +183,6 @@ 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 @@ -282,111 +251,24 @@ 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 (use all_custom_fields to allow enabling globally hidden fields) - final_selection = - FieldVisibility.merge_with_global_settings( - new_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() - |> 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() - |> 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, sort order, and payment filter, and field selection, + Parses query parameters for search query, sort field, sort order, and payment filter, 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) |> maybe_update_paid_filter(params) |> assign(:query, params["query"]) - |> 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() |> prepare_dynamic_cols() @@ -399,17 +281,10 @@ 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] || [] - - # Use all_custom_fields to allow users to enable globally hidden custom fields dynamic_cols = - socket.assigns.all_custom_fields - |> Enum.filter(fn custom_field -> custom_field.id in visible_custom_field_ids end) - |> Enum.map(fn custom_field -> + Enum.map(socket.assigns.custom_fields_visible, fn custom_field -> %{ custom_field: custom_field, render: fn member -> @@ -502,58 +377,6 @@ defmodule MvWeb.MemberLive.Index do )} end - # Builds query parameters including field selection - defp build_query_params(socket, base_params) do - # Use query from base_params if provided, otherwise fall back to socket.assigns.query - query_value = Map.get(base_params, "query") || socket.assigns.query || "" - - base_params - |> Map.put("query", query_value) - |> 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 - base_params = %{ - "sort_field" => field_to_string(socket.assigns.sort_field), - "sort_order" => Atom.to_string(socket.assigns.sort_order) - } - - # Include paid_filter if set - base_params = - case socket.assigns.paid_filter do - nil -> base_params - :paid -> Map.put(base_params, "paid_filter", "paid") - :not_paid -> Map.put(base_params, "paid_filter", "not_paid") - end - - query_params = build_query_params(socket, base_params) - 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 - # Builds URL query parameters map including all filter/sort state. # Converts paid_filter atom to string for URL. defp build_query_params(query, sort_field, sort_order, paid_filter) do @@ -612,9 +435,9 @@ defmodule MvWeb.MemberLive.Index do |> Ash.Query.new() |> Ash.Query.select(@overview_fields) - # 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) + # 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) # Apply the search filter first query = apply_search_filter(query, search_query) @@ -792,18 +615,6 @@ defmodule MvWeb.MemberLive.Index do defp extract_custom_field_id(_), do: nil - # 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_prefix) do - ["", id] -> id - _ -> nil - end - end) - |> Enum.filter(&(&1 != nil)) - end - # Sorts members in memory by a custom field value. # # Process: @@ -1100,6 +911,34 @@ defmodule MvWeb.MemberLive.Index do 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 + # Public helper function to format dates for use in templates def format_date(date), do: DateFormatter.format_date(date) end diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 1658209..959a3bc 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -44,13 +44,6 @@ paid_filter={@paid_filter} member_count={length(@members)} /> - <.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} - /> <.table @@ -92,7 +85,6 @@ <:col :let={member} - :if={:first_name in @member_fields_visible} label={ ~H""" <.live_component @@ -106,25 +98,7 @@ """ } > - {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} + {member.first_name} {member.last_name} <:col :let={member} @@ -252,7 +226,7 @@ > {MvWeb.MemberLive.Index.format_date(member.join_date)} - <:col :let={member} :if={:paid in @member_fields_visible} label={gettext("Paid")}> + <:col :let={member} label={gettext("Paid")}> 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 parses the raw Cookie header. 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 - # get_req_header always returns a list ([] if no header, [value] if present) - case Plug.Conn.get_req_header(conn, "cookie") do - [] -> - %{} - - [cookie_header | _rest] -> - 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_join(",", fn {field, _visible} -> field end) - 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}, acc when is_boolean(value) -> Map.put(acc, key, value) - {key, _value}, acc -> Map.put(acc, 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 deleted file mode 100644 index c9c8bd6..0000000 --- a/lib/mv_web/live/member_live/index/field_visibility.ex +++ /dev/null @@ -1,239 +0,0 @@ -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 - 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 - 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 - 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 - - # 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, Mv.Constants.custom_field_prefix()) 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 diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index bb781f7..3f80877 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -783,7 +783,6 @@ msgstr "Tipp: E-Mail-Adressen ins BCC-Feld einfügen für Datenschutzkonformitä msgid "This field cannot be empty" msgstr "Dieses Feld darf nicht leer bleiben" -#: lib/mv_web/components/core_components.ex #: lib/mv_web/live/components/payment_filter_component.ex #, elixir-autogen, elixir-format msgid "All" @@ -1320,41 +1319,6 @@ msgstr "" msgid "Yearly Interval - Joining Period Included" msgstr "" -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format -msgid "Columns" -msgstr "Spalten" - -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format -msgid "Custom Field %{id}" -msgstr "Benutzerdefiniertes Feld %{id}" - -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Last name" -msgstr "Nachname" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format -msgid "None" -msgstr "Keine" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format -msgid "Options" -msgstr "Optionen" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format -msgid "Select all" -msgstr "Alle auswählen" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format -msgid "Select none" -msgstr "Keine auswählen" - #: lib/mv_web/live/custom_field_live/form_component.ex #, elixir-autogen, elixir-format msgid "Back to custom field overview" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 7581d62..9a479c6 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -784,7 +784,6 @@ msgstr "" msgid "This field cannot be empty" msgstr "" -#: lib/mv_web/components/core_components.ex #: lib/mv_web/live/components/payment_filter_component.ex #, elixir-autogen, elixir-format msgid "All" @@ -1321,41 +1320,6 @@ msgstr "" msgid "Yearly Interval - Joining Period Included" msgstr "" -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format -msgid "Columns" -msgstr "" - -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format -msgid "Custom Field %{id}" -msgstr "" - -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Last name" -msgstr "" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format -msgid "None" -msgstr "" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format -msgid "Options" -msgstr "" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format -msgid "Select all" -msgstr "" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format -msgid "Select none" -msgstr "" - #: lib/mv_web/live/custom_field_live/form_component.ex #, elixir-autogen, elixir-format msgid "Back to custom field overview" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index dc86840..3d80e4d 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -784,7 +784,6 @@ msgstr "" msgid "This field cannot be empty" msgstr "" -#: lib/mv_web/components/core_components.ex #: lib/mv_web/live/components/payment_filter_component.ex #, elixir-autogen, elixir-format msgid "All" @@ -1321,41 +1320,6 @@ msgstr "" msgid "Yearly Interval - Joining Period Included" msgstr "" -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format -msgid "Columns" -msgstr "" - -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Custom Field %{id}" -msgstr "" - -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format, fuzzy -msgid "Last name" -msgstr "" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "None" -msgstr "" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Options" -msgstr "" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Select all" -msgstr "" - -#: lib/mv_web/components/core_components.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Select none" -msgstr "" - #: lib/mv_web/live/custom_field_live/form_component.ex #, elixir-autogen, elixir-format msgid "Back to custom field overview" diff --git a/test/membership/member_field_visibility_test.exs b/test/membership/member_field_visibility_test.exs deleted file mode 100644 index 9c7e5e0..0000000 --- a/test/membership/member_field_visibility_test.exs +++ /dev/null @@ -1,80 +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 - - describe "show_in_overview?/1" do - test "returns true for all member fields by default" do - # When no settings exist or member_field_visibility is not configured - # Test with fields from constants - member_fields = Mv.Constants.member_fields() - - Enum.each(member_fields, fn field -> - assert Member.show_in_overview?(field) == true, - "Field #{field} should be visible by default" - end) - end - - test "returns false for fields with show_in_overview: false in settings" do - # Get or create settings - {:ok, settings} = Mv.Membership.get_settings() - - # Use a field that exists in member fields - member_fields = Mv.Constants.member_fields() - field_to_hide = List.first(member_fields) - field_to_show = List.last(member_fields) - - # Update settings to hide a field (use string keys for JSONB) - {:ok, _updated_settings} = - Mv.Membership.update_settings(settings, %{ - member_field_visibility: %{Atom.to_string(field_to_hide) => false} - }) - - # JSONB may convert atom keys to string keys, so we check via show_in_overview? instead - assert Member.show_in_overview?(field_to_hide) == false - assert Member.show_in_overview?(field_to_show) == true - end - - test "returns true for non-configured fields (default)" do - # Get or create settings - {:ok, settings} = Mv.Membership.get_settings() - - # Use fields that exist in member fields - member_fields = Mv.Constants.member_fields() - fields_to_hide = Enum.take(member_fields, 2) - fields_to_show = Enum.take(member_fields, -2) - - # Update settings to hide some fields (use string keys for JSONB) - visibility_config = - Enum.reduce(fields_to_hide, %{}, fn field, acc -> - Map.put(acc, Atom.to_string(field), false) - end) - - {:ok, _updated_settings} = - Mv.Membership.update_settings(settings, %{ - member_field_visibility: visibility_config - }) - - # Hidden fields should be false - Enum.each(fields_to_hide, fn field -> - assert Member.show_in_overview?(field) == false, - "Field #{field} should be hidden" - end) - - # Unconfigured fields should still be true (default) - Enum.each(fields_to_show, fn field -> - assert Member.show_in_overview?(field) == true, - "Field #{field} should be visible by default" - end) - end - end -end diff --git a/test/mv_web/components/field_visibility_dropdown_component_test.exs b/test/mv_web/components/field_visibility_dropdown_component_test.exs deleted file mode 100644 index 6e01afa..0000000 --- a/test/mv_web/components/field_visibility_dropdown_component_test.exs +++ /dev/null @@ -1,21 +0,0 @@ -defmodule MvWeb.Components.FieldVisibilityDropdownComponentTest do - use MvWeb.ConnCase, async: true - import Phoenix.LiveViewTest - - describe "field visibility dropdown in member view" do - test "renders and toggles visibility", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, ~p"/members") - - # Renders Dropdown - assert has_element?(view, "[data-testid='dropdown-menu']") - - # Opens Dropdown - view |> element("[data-testid='dropdown-button']") |> render_click() - assert has_element?(view, "#field-visibility-menu") - assert has_element?(view, "button[phx-click='select_item'][phx-value-item='email']") - assert has_element?(view, "button[phx-click='select_all']") - assert has_element?(view, "button[phx-click='select_none']") - end - end -end diff --git a/test/mv_web/components/sort_header_component_test.exs b/test/mv_web/components/sort_header_component_test.exs index e199635..2e6d4fe 100644 --- a/test/mv_web/components/sort_header_component_test.exs +++ b/test/mv_web/components/sort_header_component_test.exs @@ -150,27 +150,35 @@ defmodule MvWeb.Components.SortHeaderComponentTest do assert has_element?(view, "[data-testid='email'] .opacity-40") end - test "icon distribution shows exactly one active sort icon", %{conn: conn} do + test "icon distribution is correct for all fields", %{conn: conn} do conn = conn_with_oidc_user(conn) - # Test neutral state - only one field should have active sort icon + # Test neutral state - all fields except first name (default) should show neutral icons {:ok, _view, html_neutral} = live(conn, "/members") - # Count active icons (should be exactly 1 - ascending for default sort field) + # Count neutral icons (should be 7 - one for each field) + neutral_count = + html_neutral |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1) + + assert neutral_count == 7 + + # Count active icons (should be 1) up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1) down_count = html_neutral |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1) + assert up_count == 1 + assert down_count == 0 - assert up_count == 1, "Expected exactly 1 ascending icon, got #{up_count}" - assert down_count == 0, "Expected 0 descending icons, got #{down_count}" + # Test ascending state - one field active, others neutral + {:ok, _view, html_asc} = live(conn, "/members?sort_field=first_name&sort_order=asc") - # Test descending state - {:ok, _view, html_desc} = live(conn, "/members?sort_field=first_name&sort_order=desc") + # Should have exactly 1 ascending icon and 7 neutral icons + up_count = html_asc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1) + neutral_count = html_asc |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1) + down_count = html_asc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1) - up_count = html_desc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1) - down_count = html_desc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1) - - assert up_count == 0, "Expected 0 ascending icons, got #{up_count}" - assert down_count == 1, "Expected exactly 1 descending icon, got #{down_count}" + assert up_count == 1 + assert neutral_count == 7 + assert down_count == 0 end end diff --git a/test/mv_web/live/member_live/index/field_selection_test.exs b/test/mv_web/live/member_live/index/field_selection_test.exs deleted file mode 100644 index 9d6aa77..0000000 --- a/test/mv_web/live/member_live/index/field_selection_test.exs +++ /dev/null @@ -1,370 +0,0 @@ -defmodule MvWeb.MemberLive.Index.FieldSelectionTest do - @moduledoc """ - Tests for FieldSelection module handling cookie/session/URL management. - """ - use ExUnit.Case, async: true - - alias MvWeb.MemberLive.Index.FieldSelection - - describe "get_from_session/1" do - test "returns empty map when session is empty" do - assert FieldSelection.get_from_session(%{}) == %{} - end - - test "returns empty map when session key is missing" do - session = %{"other_key" => "value"} - assert FieldSelection.get_from_session(session) == %{} - end - - test "parses valid JSON from session" do - json = Jason.encode!(%{"first_name" => true, "email" => false}) - session = %{"member_field_selection" => json} - - result = FieldSelection.get_from_session(session) - - assert result == %{"first_name" => true, "email" => false} - end - - test "handles invalid JSON gracefully" do - session = %{"member_field_selection" => "invalid json{["} - - result = FieldSelection.get_from_session(session) - - assert result == %{} - end - - test "converts non-boolean values to true" do - json = Jason.encode!(%{"first_name" => "true", "email" => 1, "street" => true}) - session = %{"member_field_selection" => json} - - result = FieldSelection.get_from_session(session) - - # All values should be booleans, non-booleans default to true - assert result["first_name"] == true - assert result["email"] == true - assert result["street"] == true - end - - test "handles nil session" do - assert FieldSelection.get_from_session(nil) == %{} - end - - test "handles non-map session" do - assert FieldSelection.get_from_session("not a map") == %{} - end - end - - describe "save_to_session/2" do - test "saves field selection to session as JSON" do - session = %{} - selection = %{"first_name" => true, "email" => false} - - result = FieldSelection.save_to_session(session, selection) - - assert Map.has_key?(result, "member_field_selection") - assert Jason.decode!(result["member_field_selection"]) == selection - end - - test "overwrites existing selection" do - session = %{"member_field_selection" => Jason.encode!(%{"old" => true})} - selection = %{"new" => true} - - result = FieldSelection.save_to_session(session, selection) - - assert Jason.decode!(result["member_field_selection"]) == selection - end - - test "handles empty selection" do - session = %{} - selection = %{} - - result = FieldSelection.save_to_session(session, selection) - - assert Jason.decode!(result["member_field_selection"]) == %{} - end - - test "handles invalid selection gracefully" do - session = %{} - - result = FieldSelection.save_to_session(session, "not a map") - - assert result == session - end - end - - describe "get_from_cookie/1" do - test "returns empty map when cookie header is missing" do - conn = %Plug.Conn{} - - result = FieldSelection.get_from_cookie(conn) - - assert result == %{} - end - - test "returns empty map when cookie is empty string" do - conn = Plug.Conn.put_req_header(%Plug.Conn{}, "cookie", "") - - result = FieldSelection.get_from_cookie(conn) - - assert result == %{} - end - - test "parses valid JSON from cookie" do - selection = %{"first_name" => true, "email" => false} - cookie_value = selection |> Jason.encode!() |> URI.encode() - cookie_header = "member_field_selection=#{cookie_value}" - conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header) - - result = FieldSelection.get_from_cookie(conn) - - assert result == selection - end - - test "handles invalid JSON in cookie gracefully" do - cookie_value = URI.encode("invalid{[") - cookie_header = "member_field_selection=#{cookie_value}" - conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header) - - result = FieldSelection.get_from_cookie(conn) - - assert result == %{} - end - - test "handles cookie with other values" do - selection = %{"street" => true} - cookie_value = selection |> Jason.encode!() |> URI.encode() - cookie_header = "other_cookie=value; member_field_selection=#{cookie_value}; another=test" - conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header) - - result = FieldSelection.get_from_cookie(conn) - - assert result == selection - end - end - - describe "save_to_cookie/2" do - test "saves field selection to cookie" do - conn = %Plug.Conn{} - selection = %{"first_name" => true, "email" => false} - - result = FieldSelection.save_to_cookie(conn, selection) - - # Check that cookie is set - assert result.resp_cookies["member_field_selection"] - cookie = result.resp_cookies["member_field_selection"] - assert cookie[:max_age] == 365 * 24 * 60 * 60 - assert cookie[:same_site] == "Lax" - assert cookie[:http_only] == true - end - - test "handles invalid selection gracefully" do - conn = %Plug.Conn{} - - result = FieldSelection.save_to_cookie(conn, "not a map") - - assert result == conn - end - end - - describe "parse_from_url/1" do - test "returns empty map when params is empty" do - assert FieldSelection.parse_from_url(%{}) == %{} - end - - test "returns empty map when fields parameter is missing" do - params = %{"query" => "test", "sort_field" => "first_name"} - assert FieldSelection.parse_from_url(params) == %{} - end - - test "parses comma-separated field names" do - params = %{"fields" => "first_name,email,street"} - - result = FieldSelection.parse_from_url(params) - - assert result == %{ - "first_name" => true, - "email" => true, - "street" => true - } - end - - test "handles custom field names" do - params = %{"fields" => "custom_field_abc-123,custom_field_def-456"} - - result = FieldSelection.parse_from_url(params) - - assert result == %{ - "custom_field_abc-123" => true, - "custom_field_def-456" => true - } - end - - test "handles mixed member and custom fields" do - params = %{"fields" => "first_name,custom_field_123,email"} - - result = FieldSelection.parse_from_url(params) - - assert result == %{ - "first_name" => true, - "custom_field_123" => true, - "email" => true - } - end - - test "trims whitespace from field names" do - params = %{"fields" => " first_name , email , street "} - - result = FieldSelection.parse_from_url(params) - - assert result == %{ - "first_name" => true, - "email" => true, - "street" => true - } - end - - test "handles empty fields string" do - params = %{"fields" => ""} - assert FieldSelection.parse_from_url(params) == %{} - end - - test "handles nil fields parameter" do - params = %{"fields" => nil} - assert FieldSelection.parse_from_url(params) == %{} - end - - test "filters out empty field names" do - params = %{"fields" => "first_name,,email,"} - - result = FieldSelection.parse_from_url(params) - - assert result == %{ - "first_name" => true, - "email" => true - } - end - - test "handles non-map params" do - assert FieldSelection.parse_from_url(nil) == %{} - assert FieldSelection.parse_from_url("not a map") == %{} - end - end - - describe "merge_sources/3" do - test "merges all sources with URL having highest priority" do - url_selection = %{"first_name" => false} - session_selection = %{"first_name" => true, "email" => true} - cookie_selection = %{"first_name" => true, "street" => true} - - result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection) - - # URL overrides session, session overrides cookie - assert result["first_name"] == false - assert result["email"] == true - assert result["street"] == true - end - - test "handles empty sources" do - result = FieldSelection.merge_sources(%{}, %{}, %{}) - - assert result == %{} - end - - test "cookie only" do - cookie_selection = %{"first_name" => true} - - result = FieldSelection.merge_sources(%{}, %{}, cookie_selection) - - assert result == %{"first_name" => true} - end - - test "session overrides cookie" do - session_selection = %{"first_name" => false} - cookie_selection = %{"first_name" => true} - - result = FieldSelection.merge_sources(%{}, session_selection, cookie_selection) - - assert result["first_name"] == false - end - - test "URL overrides everything" do - url_selection = %{"first_name" => true} - session_selection = %{"first_name" => false} - cookie_selection = %{"first_name" => false} - - result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection) - - assert result["first_name"] == true - end - - test "combines fields from all sources" do - url_selection = %{"url_field" => true} - session_selection = %{"session_field" => true} - cookie_selection = %{"cookie_field" => true} - - result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection) - - assert result["url_field"] == true - assert result["session_field"] == true - assert result["cookie_field"] == true - end - end - - describe "to_url_param/1" do - test "converts selection to comma-separated string" do - selection = %{"first_name" => true, "email" => true, "street" => false} - - result = FieldSelection.to_url_param(selection) - - # Only visible fields should be included (order may vary) - fields = String.split(result, ",") |> Enum.sort() - assert fields == ["email", "first_name"] - end - - test "handles empty selection" do - assert FieldSelection.to_url_param(%{}) == "" - end - - test "handles all fields hidden" do - selection = %{"first_name" => false, "email" => false} - - result = FieldSelection.to_url_param(selection) - - assert result == "" - end - - test "preserves field order" do - selection = %{ - "z_field" => true, - "a_field" => true, - "m_field" => true - } - - result = FieldSelection.to_url_param(selection) - - # Order should be preserved (map iteration order) - assert String.contains?(result, "z_field") - assert String.contains?(result, "a_field") - assert String.contains?(result, "m_field") - end - - test "handles custom fields" do - selection = %{ - "first_name" => true, - "custom_field_abc-123" => true, - "email" => false - } - - result = FieldSelection.to_url_param(selection) - - assert String.contains?(result, "first_name") - assert String.contains?(result, "custom_field_abc-123") - refute String.contains?(result, "email") - end - - test "handles invalid input" do - assert FieldSelection.to_url_param(nil) == "" - assert FieldSelection.to_url_param("not a map") == "" - end - end -end diff --git a/test/mv_web/live/member_live/index/field_visibility_test.exs b/test/mv_web/live/member_live/index/field_visibility_test.exs deleted file mode 100644 index 83ae06d..0000000 --- a/test/mv_web/live/member_live/index/field_visibility_test.exs +++ /dev/null @@ -1,336 +0,0 @@ -defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do - @moduledoc """ - Tests for FieldVisibility module handling field visibility merging logic. - """ - use ExUnit.Case, async: true - - alias MvWeb.MemberLive.Index.FieldVisibility - - # Mock custom field structs for testing - defp create_custom_field(id, name, show_in_overview \\ true) do - %{ - id: id, - name: name, - show_in_overview: show_in_overview - } - end - - describe "get_all_available_fields/1" do - test "returns member fields and custom fields" do - custom_fields = [ - create_custom_field("cf1", "Custom Field 1"), - create_custom_field("cf2", "Custom Field 2") - ] - - result = FieldVisibility.get_all_available_fields(custom_fields) - - # Should include all member fields - assert :first_name in result - assert :email in result - assert :street in result - - # Should include custom fields as strings - assert "custom_field_cf1" in result - assert "custom_field_cf2" in result - end - - test "handles empty custom fields list" do - result = FieldVisibility.get_all_available_fields([]) - - # Should only have member fields - assert :first_name in result - assert :email in result - - refute Enum.any?(result, fn field -> - is_binary(field) and String.starts_with?(field, "custom_field_") - end) - end - - test "includes all member fields from constants" do - custom_fields = [] - result = FieldVisibility.get_all_available_fields(custom_fields) - - member_fields = Mv.Constants.member_fields() - - Enum.each(member_fields, fn field -> - assert field in result - end) - end - end - - describe "merge_with_global_settings/3" do - test "user selection overrides global settings" do - user_selection = %{"first_name" => false} - settings = %{member_field_visibility: %{first_name: true, email: true}} - custom_fields = [] - - result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - - assert result["first_name"] == false - assert result["email"] == true - end - - test "falls back to global settings when user selection is empty" do - user_selection = %{} - settings = %{member_field_visibility: %{first_name: false, email: true}} - custom_fields = [] - - result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - - assert result["first_name"] == false - assert result["email"] == true - end - - test "defaults to true when field not in settings" do - user_selection = %{} - settings = %{member_field_visibility: %{first_name: false}} - custom_fields = [] - - result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - - # first_name from settings - assert result["first_name"] == false - # email defaults to true (not in settings) - assert result["email"] == true - end - - test "handles custom fields visibility" do - user_selection = %{} - settings = %{member_field_visibility: %{}} - - custom_fields = [ - create_custom_field("cf1", "Custom 1", true), - create_custom_field("cf2", "Custom 2", false) - ] - - result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - - assert result["custom_field_cf1"] == true - assert result["custom_field_cf2"] == false - end - - test "user selection overrides custom field visibility" do - user_selection = %{"custom_field_cf1" => false} - settings = %{member_field_visibility: %{}} - - custom_fields = [ - create_custom_field("cf1", "Custom 1", true) - ] - - result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - - assert result["custom_field_cf1"] == false - end - - test "handles string keys in settings (JSONB format)" do - user_selection = %{} - settings = %{member_field_visibility: %{"first_name" => false, "email" => true}} - custom_fields = [] - - result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - - assert result["first_name"] == false - assert result["email"] == true - end - - test "handles mixed atom and string keys in settings" do - user_selection = %{} - # Use string keys only (as JSONB would return) - settings = %{member_field_visibility: %{"first_name" => false, "email" => true}} - custom_fields = [] - - result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - - assert result["first_name"] == false - assert result["email"] == true - end - - test "handles nil settings gracefully" do - user_selection = %{} - settings = %{member_field_visibility: nil} - custom_fields = [] - - result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - - # Should default all fields to true - assert result["first_name"] == true - assert result["email"] == true - end - - test "handles missing member_field_visibility key" do - user_selection = %{} - settings = %{} - custom_fields = [] - - result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - - # Should default all fields to true - assert result["first_name"] == true - assert result["email"] == true - end - - test "includes all fields in result" do - user_selection = %{"first_name" => false} - settings = %{member_field_visibility: %{email: true}} - - custom_fields = [ - create_custom_field("cf1", "Custom 1", true) - ] - - result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - - # Should include all member fields - member_fields = Mv.Constants.member_fields() - - Enum.each(member_fields, fn field -> - assert Map.has_key?(result, Atom.to_string(field)) - end) - - # Should include custom fields - assert Map.has_key?(result, "custom_field_cf1") - end - end - - describe "get_visible_fields/1" do - test "returns only fields with true visibility" do - selection = %{ - "first_name" => true, - "email" => false, - "street" => true, - "custom_field_123" => false - } - - result = FieldVisibility.get_visible_fields(selection) - - assert :first_name in result - assert :street in result - refute :email in result - refute "custom_field_123" in result - end - - test "converts member field strings to atoms" do - selection = %{"first_name" => true, "email" => true} - - result = FieldVisibility.get_visible_fields(selection) - - assert :first_name in result - assert :email in result - end - - test "keeps custom fields as strings" do - selection = %{"custom_field_abc-123" => true} - - result = FieldVisibility.get_visible_fields(selection) - - assert "custom_field_abc-123" in result - end - - test "handles empty selection" do - assert FieldVisibility.get_visible_fields(%{}) == [] - end - - test "handles all fields hidden" do - selection = %{"first_name" => false, "email" => false} - - assert FieldVisibility.get_visible_fields(selection) == [] - end - - test "handles invalid input" do - assert FieldVisibility.get_visible_fields(nil) == [] - end - end - - describe "get_visible_member_fields/1" do - test "returns only member fields that are visible" do - selection = %{ - "first_name" => true, - "email" => true, - "custom_field_123" => true, - "street" => false - } - - result = FieldVisibility.get_visible_member_fields(selection) - - assert :first_name in result - assert :email in result - refute :street in result - refute "custom_field_123" in result - end - - test "filters out custom fields" do - selection = %{ - "first_name" => true, - "custom_field_123" => true, - "custom_field_456" => true - } - - result = FieldVisibility.get_visible_member_fields(selection) - - assert :first_name in result - refute "custom_field_123" in result - refute "custom_field_456" in result - end - - test "handles empty selection" do - assert FieldVisibility.get_visible_member_fields(%{}) == [] - end - - test "handles invalid input" do - assert FieldVisibility.get_visible_member_fields(nil) == [] - end - end - - describe "get_visible_custom_fields/1" do - test "returns only custom fields that are visible" do - selection = %{ - "first_name" => true, - "custom_field_123" => true, - "custom_field_456" => false, - "email" => true - } - - result = FieldVisibility.get_visible_custom_fields(selection) - - assert "custom_field_123" in result - refute "custom_field_456" in result - refute :first_name in result - refute :email in result - end - - test "filters out member fields" do - selection = %{ - "first_name" => true, - "email" => true, - "custom_field_123" => true - } - - result = FieldVisibility.get_visible_custom_fields(selection) - - assert "custom_field_123" in result - refute :first_name in result - refute :email in result - end - - test "handles empty selection" do - assert FieldVisibility.get_visible_custom_fields(%{}) == [] - end - - test "handles fields that look like custom fields but aren't" do - selection = %{ - "custom_field_123" => true, - "custom_field_like_name" => true, - "not_custom_field" => true - } - - result = FieldVisibility.get_visible_custom_fields(selection) - - assert "custom_field_123" in result - assert "custom_field_like_name" in result - refute "not_custom_field" in result - end - - test "handles invalid input" do - assert FieldVisibility.get_visible_custom_fields(nil) == [] - end - end -end diff --git a/test/mv_web/member_live/index_custom_fields_display_test.exs b/test/mv_web/member_live/index_custom_fields_display_test.exs index b720099..802cc8f 100644 --- a/test/mv_web/member_live/index_custom_fields_display_test.exs +++ b/test/mv_web/member_live/index_custom_fields_display_test.exs @@ -242,7 +242,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do assert html =~ "alice.private@example.com" end - test "shows empty cell for members without custom field values", %{ + test "shows empty cell or placeholder for members without custom field values", %{ conn: conn, member2: _member2, field_show_string: field @@ -253,14 +253,11 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do # The custom field column should exist assert html =~ field.name - # Member2 should exist in the table (first_name and last_name are in separate columns) - assert html =~ "Bob" - assert html =~ "Brown" - - # The value from member1 should appear (phone number) + # Member2 should have an empty cell for this field + # We check that member2's row exists but doesn't have the value + assert html =~ "Bob Brown" + # The value should not appear for member2 (only for member1) + # We check that the value appears somewhere (for member1) but member2 row should have "-" assert html =~ "+49123456789" - - # Note: Member2 doesn't have this custom field value, so the cell is empty - # The implementation shows "" for missing values, which is the expected behavior end end diff --git a/test/mv_web/member_live/index_field_visibility_test.exs b/test/mv_web/member_live/index_field_visibility_test.exs deleted file mode 100644 index 6e1642a..0000000 --- a/test/mv_web/member_live/index_field_visibility_test.exs +++ /dev/null @@ -1,452 +0,0 @@ -defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do - @moduledoc """ - Integration tests for field visibility dropdown functionality. - - Tests cover: - - Field selection dropdown rendering - - Toggling field visibility - - URL parameter persistence - - Select all / deselect all - - Integration with member list display - - Custom fields visibility - """ - use MvWeb.ConnCase, async: true - - import Phoenix.LiveViewTest - require Ash.Query - - alias Mv.Membership.{CustomField, CustomFieldValue, Member} - - setup do - # Create test members - {:ok, member1} = - Member - |> Ash.Changeset.for_create(:create_member, %{ - first_name: "Alice", - last_name: "Anderson", - email: "alice@example.com", - street: "Main St", - city: "Berlin" - }) - |> Ash.create() - - {:ok, member2} = - Member - |> Ash.Changeset.for_create(:create_member, %{ - first_name: "Bob", - last_name: "Brown", - email: "bob@example.com", - street: "Second St", - city: "Hamburg" - }) - |> Ash.create() - - # Create custom field - {:ok, custom_field} = - CustomField - |> Ash.Changeset.for_create(:create, %{ - name: "membership_number", - value_type: :string, - show_in_overview: true - }) - |> Ash.create() - - # Create custom field values - {:ok, _cfv1} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member1.id, - custom_field_id: custom_field.id, - value: "M001" - }) - |> Ash.create() - - {:ok, _cfv2} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member2.id, - custom_field_id: custom_field.id, - value: "M002" - }) - |> Ash.create() - - %{ - member1: member1, - member2: member2, - custom_field: custom_field - } - end - - describe "field visibility dropdown" do - test "renders dropdown button", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members") - - assert html =~ "Columns" - assert html =~ ~s(aria-controls="field-visibility-menu") - end - - test "opens dropdown when button is clicked", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Initially closed - refute has_element?(view, "ul#field-visibility-menu") - - # Click button - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - # Should be open now - assert has_element?(view, "ul#field-visibility-menu") - end - - test "displays all member fields in dropdown", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - html = render(view) - - # Check for member fields (formatted labels) - assert html =~ "First Name" or html =~ "first_name" - assert html =~ "Email" or html =~ "email" - assert html =~ "Street" or html =~ "street" - end - - test "displays custom fields in dropdown", %{conn: conn, custom_field: custom_field} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - html = render(view) - - assert html =~ custom_field.name - end - end - - describe "field visibility toggling" do - test "hiding a field removes it from display", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Verify email is visible initially - html = render(view) - assert html =~ "alice@example.com" - - # Open dropdown and hide email - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - view - |> element("button[phx-click='select_item'][phx-value-item='email']") - |> render_click() - - # Wait for update - :timer.sleep(100) - - # Email should no longer be visible - html = render(view) - refute html =~ "alice@example.com" - refute html =~ "bob@example.com" - end - - test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Verify custom field is visible initially - html = render(view) - assert html =~ "M001" or html =~ custom_field.name - - # Open dropdown and hide custom field - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - custom_field_id = custom_field.id - custom_field_string = "custom_field_#{custom_field_id}" - - view - |> element("button[phx-click='select_item'][phx-value-item='#{custom_field_string}']") - |> render_click() - - # Wait for update - :timer.sleep(100) - - # Custom field should no longer be visible - html = render(view) - refute html =~ "M001" - refute html =~ "M002" - end - end - - describe "select all / deselect all" do - test "select all makes all fields visible", %{conn: conn} do - conn = conn_with_oidc_user(conn) - - # Start with some fields hidden - {:ok, view, _html} = live(conn, "/members?fields=first_name") - - # Open dropdown - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - # Click select all - view - |> element("button[phx-click='select_all']") - |> render_click() - - # Wait for update - :timer.sleep(100) - - # All fields should be visible - html = render(view) - assert html =~ "alice@example.com" - assert html =~ "Main St" - assert html =~ "Berlin" - end - - test "deselect all hides all fields except first_name", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - # Click deselect all - view - |> element("button[phx-click='select_none']") - |> render_click() - - # Wait for update - :timer.sleep(100) - - # Only first_name should be visible (it's always shown) - html = render(view) - # Email and street should be hidden - refute html =~ "alice@example.com" - refute html =~ "Main St" - end - end - - describe "URL parameter persistence" do - test "field selection is persisted in URL", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown and hide email - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - view - |> element("button[phx-click='select_item'][phx-value-item='email']") - |> render_click() - - # Wait for URL update - :timer.sleep(100) - - # Check that URL contains fields parameter - # Note: In LiveView tests, we check the rendered HTML for the updated state - # The actual URL update happens via push_patch - end - - test "loading page with fields parameter applies selection", %{conn: conn} do - conn = conn_with_oidc_user(conn) - - # Load with first_name and city explicitly set in URL - # Note: Other fields may still be visible due to global settings - {:ok, view, _html} = live(conn, "/members?fields=first_name,city") - - html = render(view) - - # first_name and city should be visible - assert html =~ "Alice" - assert html =~ "Berlin" - - # Note: email and street may still be visible if global settings allow it - # This test verifies that the URL parameters work, not that they hide other fields - end - - test "fields parameter works with custom fields", %{conn: conn, custom_field: custom_field} do - conn = conn_with_oidc_user(conn) - custom_field_id = custom_field.id - - # Load with custom field visible - {:ok, view, _html} = - live(conn, "/members?fields=first_name,custom_field_#{custom_field_id}") - - html = render(view) - - # Custom field should be visible - assert html =~ "M001" or html =~ custom_field.name - end - end - - describe "integration with global settings" do - test "respects global settings when no user selection", %{conn: conn} do - # This test would require setting up global settings - # For now, we verify that the system works with default settings - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members") - - # All fields should be visible by default - assert html =~ "alice@example.com" - assert html =~ "Main St" - end - - test "user selection overrides global settings", %{conn: conn} do - # This would require setting up global settings first - # Then verifying that user selection takes precedence - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Hide a field via dropdown - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - view - |> element("button[phx-click='select_item'][phx-value-item='email']") - |> render_click() - - :timer.sleep(100) - - html = render(view) - refute html =~ "alice@example.com" - end - end - - describe "edge cases" do - test "handles empty fields parameter", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members?fields=") - - # Should fall back to global settings - assert html =~ "alice@example.com" - end - - test "handles invalid field names in URL", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid") - - # Should ignore invalid fields and use defaults - assert html =~ "alice@example.com" - end - - test "handles custom field that doesn't exist", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members?fields=first_name,custom_field_nonexistent") - - # Should work without errors - assert html =~ "Alice" - end - - test "handles rapid toggling", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - # Rapidly toggle a field multiple times - for _ <- 1..5 do - view - |> element("button[phx-click='select_item'][phx-value-item='email']") - |> render_click() - - :timer.sleep(50) - end - - # Should still work correctly - html = render(view) - assert html =~ "Alice" - end - end - - describe "accessibility" do - test "dropdown has proper ARIA attributes", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members") - - assert html =~ ~s(aria-controls="field-visibility-menu") - assert html =~ ~s(aria-haspopup="menu") - assert html =~ ~s(role="button") - end - - test "menu items have proper ARIA attributes when open", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - html = render(view) - - assert html =~ ~s(role="menu") - assert html =~ ~s(role="menuitemcheckbox") - assert html =~ ~s(aria-checked) - end - - test "keyboard navigation works", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - # Check that elements are keyboard accessible - html = render(view) - assert html =~ ~s(tabindex="0") - # Check that keyboard events are supported - assert html =~ ~s(phx-keydown="select_item") - assert html =~ ~s(phx-key="Enter") - end - - test "keyboard activation with Enter key works", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - # Verify email is visible initially - html = render(view) - assert html =~ "alice@example.com" - - # Open dropdown - view - |> element("button[aria-controls='field-visibility-menu']") - |> render_click() - - # Simulate Enter key press on email field button - view - |> element("button[phx-click='select_item'][phx-value-item='email']") - |> render_keydown(%{key: "Enter"}) - - # Wait for update - :timer.sleep(100) - - # Email should no longer be visible - html = render(view) - refute html =~ "alice@example.com" - end - end -end