diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index 51d0749..5669a19 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -329,6 +329,11 @@ end --- +**PR #208:** *Show custom fields per default in member overview* 🔧 +- added show_in_overview as attribute to custom fields +- show custom fields in member overview per default +- can be set to false in the settings for the specific custom field + ## Implementation Decisions ### Architecture Patterns @@ -390,6 +395,7 @@ defmodule Mv.Membership.CustomField do attribute :value_type, :atom # :string, :integer, :boolean, :date, :email attribute :immutable, :boolean # Can't change after creation attribute :required, :boolean # All members must have this + attribute :show_in_overview, :boolean # "If true, this custom field will be displayed in the member overview table" end # CustomFieldValue stores values diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 9a6517d..2313fd7 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -94,15 +94,18 @@ - ✅ CustomFieldValue type management - ✅ Dynamic custom field value assignment to members - ✅ Union type storage (JSONB) +- ✅ Default field visibility configuration + +**Closed Issues:** +- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S) +- [#197](https://git.local-it.org/local-it/mitgliederverwaltung/issues/197) - Custom Fields: Add option to show custom fields in member overview (M) **Open Issues:** -- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S) [0/3 tasks] - [#157](https://git.local-it.org/local-it/mitgliederverwaltung/issues/157) - Concept how custom fields are handled (M, High priority) [0/4 tasks] - [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Don't show birthday field for default configurations (S, Low priority) - [#153](https://git.local-it.org/local-it/mitgliederverwaltung/issues/153) - Sorting functionalities for custom fields (M, Low priority) **Missing Features:** -- ❌ Default field visibility configuration - ❌ Field groups/categories - ❌ Conditional fields (show field X if field Y = value) - ❌ Field validation rules (min/max, regex patterns) diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index e1cf397..5b7514c 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -14,6 +14,7 @@ defmodule Mv.Membership.CustomField do - `description` - Optional human-readable description - `immutable` - If true, custom field values cannot be changed after creation - `required` - If true, all members must have this custom field (future feature) + - `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted ## Supported Value Types - `:string` - Text data (max 10,000 characters) @@ -59,10 +60,10 @@ defmodule Mv.Membership.CustomField do actions do defaults [:read, :update] - default_accept [:name, :value_type, :description, :immutable, :required] + default_accept [:name, :value_type, :description, :immutable, :required, :show_in_overview] create :create do - accept [:name, :value_type, :description, :immutable, :required] + accept [:name, :value_type, :description, :immutable, :required, :show_in_overview] change Mv.Membership.CustomField.Changes.GenerateSlug validate string_length(:slug, min: 1) end @@ -119,6 +120,12 @@ defmodule Mv.Membership.CustomField do attribute :required, :boolean, default: false, allow_nil?: false + + attribute :show_in_overview, :boolean, + default: true, + allow_nil?: false, + public?: true, + description: "If true, this custom field will be displayed in the member overview table" end relationships do diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 656d3c0..b8fe0fc 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -318,6 +318,13 @@ defmodule MvWeb.CoreComponents do default: &Function.identity/1, doc: "the function for mapping each row before calling the :col and :action slots" + attr :dynamic_cols, :list, + default: [], + doc: "list of dynamic column definitions with :custom_field and :render functions" + + attr :sort_field, :any, default: nil, doc: "current sort field" + attr :sort_order, :atom, default: nil, doc: "current sort order" + slot :col, required: true do attr :label, :string end @@ -335,6 +342,16 @@ defmodule MvWeb.CoreComponents do {col[:label]} + + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:"sort_custom_field_#{dyn_col[:custom_field].id}"} + field={"custom_field_#{dyn_col[:custom_field].id}"} + label={dyn_col[:custom_field].name} + sort_field={@sort_field} + sort_order={@sort_order} + /> + {gettext("Actions")} @@ -349,6 +366,23 @@ defmodule MvWeb.CoreComponents do > {render_slot(col, @row_item.(row))} + + {if dyn_col[:render] do + rendered = dyn_col[:render].(@row_item.(row)) + + if rendered == "" do + "" + else + rendered + end + else + "" + end} +
<%= for action <- @action do %> diff --git a/lib/mv_web/live/custom_field_live/form.ex b/lib/mv_web/live/custom_field_live/form.ex index ab8f104..99317a9 100644 --- a/lib/mv_web/live/custom_field_live/form.ex +++ b/lib/mv_web/live/custom_field_live/form.ex @@ -18,6 +18,7 @@ defmodule MvWeb.CustomFieldLive.Form do - description - Human-readable explanation - immutable - If true, values cannot be changed after creation (default: false) - required - If true, all members must have this custom field (default: false) + - show_in_overview - If true, this custom field will be displayed in the member overview table (default: true) ## Value Type Selection - `:string` - Text data (unlimited length) @@ -60,6 +61,7 @@ defmodule MvWeb.CustomFieldLive.Form do <.input field={@form[:description]} type="text" label={gettext("Description")} /> <.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} /> <.input field={@form[:required]} type="checkbox" label={gettext("Required")} /> + <.input field={@form[:show_in_overview]} type="checkbox" label={gettext("Show in overview")} /> <.button phx-disable-with={gettext("Saving...")} variant="primary"> {gettext("Save Custom field")} diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index c933133..85ee4fb 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -26,6 +26,14 @@ defmodule MvWeb.MemberLive.Index do """ use MvWeb, :live_view + require Ash.Query + import Ash.Expr + + alias MvWeb.MemberLive.Index.Formatter + + # Prefix used in sort field names for custom fields (e.g., "custom_field_") + @custom_field_prefix "custom_field_" + @doc """ Initializes the LiveView state. @@ -34,6 +42,16 @@ defmodule MvWeb.MemberLive.Index do """ @impl true 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. + custom_fields_visible = + Mv.Membership.CustomField + |> Ash.Query.filter(expr(show_in_overview == true)) + |> Ash.Query.sort(name: :asc) + |> Ash.read!() + socket = socket |> assign(:page_title, gettext("Members")) @@ -41,6 +59,7 @@ defmodule MvWeb.MemberLive.Index do |> assign_new(:sort_field, fn -> :first_name end) |> assign_new(:sort_order, fn -> :asc end) |> assign(:selected_members, []) + |> assign(:custom_fields_visible, custom_fields_visible) # We call handle params to use the query from the URL {:ok, socket} @@ -60,6 +79,8 @@ defmodule MvWeb.MemberLive.Index do """ @impl true def handle_event("delete", %{"id" => id}, socket) do + # Note: Using bang versions (!) - errors will be handled by Phoenix LiveView + # This ensures users see error messages if deletion fails (e.g., permission denied) member = Ash.get!(Mv.Membership.Member, id) Ash.destroy!(member) @@ -108,7 +129,14 @@ defmodule MvWeb.MemberLive.Index do """ @impl true def handle_info({:sort, field_str}, socket) do - field = String.to_existing_atom(field_str) + # Handle both atom and string field names (for custom fields) + field = + try do + String.to_existing_atom(field_str) + rescue + ArgumentError -> field_str + end + {new_field, new_order} = determine_new_sort(field, socket) socket @@ -158,10 +186,38 @@ defmodule MvWeb.MemberLive.Index do |> maybe_update_search(params) |> maybe_update_sort(params) |> load_members(params["query"]) + |> prepare_dynamic_cols() {:noreply, socket} end + # Prepares dynamic column definitions for custom fields that should be shown in the overview. + # + # Creates a list of column definitions, each containing: + # - `:custom_field` - The CustomField resource + # - `:render` - A function that formats the custom field value for a given member + # + # Returns the socket with `:dynamic_cols` assigned. + defp prepare_dynamic_cols(socket) do + dynamic_cols = + Enum.map(socket.assigns.custom_fields_visible, fn custom_field -> + %{ + custom_field: custom_field, + render: fn member -> + case get_custom_field_value(member, custom_field) do + nil -> + "" + + cfv -> + Formatter.format_custom_field_value(cfv.value, custom_field) + end + end + } + end) + + assign(socket, :dynamic_cols, dynamic_cols) + end + # ------------------------------------------------------------- # FUNCTIONS # ------------------------------------------------------------- @@ -177,8 +233,8 @@ defmodule MvWeb.MemberLive.Index do # Updates both the active and old SortHeader components defp update_sort_components(socket, old_field, new_field, new_order) do - active_id = :"sort_#{new_field}" - old_id = :"sort_#{old_field}" + active_id = to_sort_id(new_field) + old_id = to_sort_id(old_field) # Update the new SortHeader send_update(MvWeb.Components.SortHeaderComponent, @@ -197,11 +253,32 @@ defmodule MvWeb.MemberLive.Index do socket end + # Converts a field (atom or string) to a sort component ID atom + # Handles both existing atoms and strings that need to be converted + defp to_sort_id(field) when is_binary(field) do + try do + String.to_existing_atom("sort_#{field}") + rescue + ArgumentError -> :"sort_#{field}" + end + end + + defp to_sort_id(field) when is_atom(field) do + :"sort_#{field}" + end + # Builds sort URL and pushes navigation patch defp push_sort_url(socket, field, order) do + field_str = + if is_atom(field) do + Atom.to_string(field) + else + field + end + query_params = %{ "query" => socket.assigns.query, - "sort_field" => Atom.to_string(field), + "sort_field" => field_str, "sort_order" => Atom.to_string(order) } @@ -214,7 +291,24 @@ defmodule MvWeb.MemberLive.Index do )} end - # Load members eg based on a query for sorting + # Loads members from the database with custom field values and applies search/sort filters. + # + # Process: + # 1. Builds base query with selected fields + # 2. Loads custom field values for visible custom fields (filtered at database level) + # 3. Applies search filter if provided + # 4. Applies sorting (database-level for regular fields, in-memory for custom fields) + # + # Performance Considerations: + # - Database-level filtering: Custom field values are filtered directly in the database + # using Ash relationship filters, reducing memory usage and improving performance. + # - In-memory sorting: Custom field sorting is done in memory after loading. + # This is suitable for small to medium datasets (<1000 members). + # For larger datasets, consider implementing database-level sorting or pagination. + # - No pagination: All matching members are loaded at once. For large result sets, + # consider implementing pagination (see Issue #165). + # + # Returns the socket with `:members` assigned. defp load_members(socket, search_query) do query = Mv.Membership.Member @@ -232,16 +326,71 @@ defmodule MvWeb.MemberLive.Index do :join_date ]) + # 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) # Apply sorting based on current socket state - query = maybe_sort(query, socket.assigns.sort_field, socket.assigns.sort_order) + # For custom fields, we sort after loading + {query, sort_after_load} = + maybe_sort( + query, + socket.assigns.sort_field, + socket.assigns.sort_order, + socket.assigns.custom_fields_visible + ) + # Note: Using Ash.read! - errors will be handled by Phoenix LiveView + # This is appropriate for data loading in LiveViews members = Ash.read!(query) + + # Custom field values are already filtered at the database level in load_custom_field_values/2 + # No need for in-memory filtering anymore + + # Sort in memory if needed (for custom fields) + members = + if sort_after_load do + sort_members_in_memory( + members, + socket.assigns.sort_field, + socket.assigns.sort_order, + socket.assigns.custom_fields_visible + ) + else + members + end + assign(socket, :members, members) end + # Load custom field values for the given custom field IDs + # + # Filters custom field values directly in the database using Ash relationship filters. + # This is more efficient than loading all values and filtering in memory. + # + # Performance: Database-level filtering reduces: + # - Memory usage (only visible custom field values are loaded) + # - Network transfer (less data from database to application) + # - Processing time (no need to iterate through all members and filter) + defp load_custom_field_values(query, []) do + query + end + + defp load_custom_field_values(query, custom_field_ids) when length(custom_field_ids) > 0 do + # Filter custom field values at the database level using Ash relationship query + # This ensures only visible custom field values are loaded + custom_field_values_query = + Mv.Membership.CustomFieldValue + |> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids)) + |> Ash.Query.load(custom_field: [:id, :name, :value_type]) + + query + |> Ash.Query.load(custom_field_values: custom_field_values_query) + end + # ------------------------------------------------------------- # Helper Functions # ------------------------------------------------------------- @@ -264,15 +413,24 @@ defmodule MvWeb.MemberLive.Index do defp toggle_order(nil), do: :asc # Function to sort the column if needed - defp maybe_sort(query, nil, _), do: query + # Returns {query, sort_after_load} where sort_after_load is true if we need to sort in memory + defp maybe_sort(query, nil, _, _), do: {query, false} - defp maybe_sort(query, field, :asc) when not is_nil(field), - do: Ash.Query.sort(query, [{field, :asc}]) + defp maybe_sort(query, field, order, _custom_fields) when not is_nil(field) do + if custom_field_sort?(field) do + # Custom fields need to be sorted in memory after loading + {query, true} + else + # Only sort by atom fields (regular member fields) in database + if is_atom(field) do + {Ash.Query.sort(query, [{field, order}]), false} + else + {query, false} + end + end + end - defp maybe_sort(query, field, :desc) when not is_nil(field), - do: Ash.Query.sort(query, [{field, :desc}]) - - defp maybe_sort(query, _, _), do: query + defp maybe_sort(query, _, _, _), do: {query, false} # Validate that a field is sortable defp valid_sort_field?(field) when is_atom(field) do @@ -288,12 +446,188 @@ defmodule MvWeb.MemberLive.Index do :join_date ] - field in valid_fields + field in valid_fields or custom_field_sort?(field) + end + + defp valid_sort_field?(field) when is_binary(field) do + custom_field_sort?(field) end defp valid_sort_field?(_), do: false - # Function to maybe update the sort + # Check if field is a custom field sort field (format: custom_field_) + defp custom_field_sort?(field) when is_atom(field) do + field_str = Atom.to_string(field) + String.starts_with?(field_str, @custom_field_prefix) + end + + defp custom_field_sort?(field) when is_binary(field) do + String.starts_with?(field, @custom_field_prefix) + end + + defp custom_field_sort?(_), do: false + + # Extracts the custom field ID from a sort field name. + # + # Sort fields for custom fields use the format: "custom_field_" + # This function extracts the ID part. + # + # Examples: + # extract_custom_field_id("custom_field_123") -> "123" + # extract_custom_field_id(:custom_field_123) -> "123" + # extract_custom_field_id("first_name") -> nil + defp extract_custom_field_id(field) when is_atom(field) do + field_str = Atom.to_string(field) + extract_custom_field_id(field_str) + end + + defp extract_custom_field_id(field) when is_binary(field) do + case String.split(field, @custom_field_prefix) do + ["", id_str] -> id_str + _ -> nil + end + end + + defp extract_custom_field_id(_), do: nil + + # Sorts members in memory by a custom field value. + # + # Process: + # 1. Extracts custom field ID from sort field name + # 2. Finds the corresponding CustomField resource + # 3. Splits members into those with values and those without + # 4. Sorts members with values by the extracted value + # 5. Combines: sorted values first, then NULL/empty values at the end + # + # Performance Note: + # This function sorts in memory, which is suitable for small to medium datasets (<1000 members). + # For larger datasets, consider implementing database-level sorting or pagination. + # + # Parameters: + # - `members` - List of Member resources to sort + # - `field` - Sort field name (format: "custom_field_" or atom) + # - `order` - Sort order (`:asc` or `:desc`) + # - `custom_fields` - List of visible CustomField resources + # + # Returns the sorted list of members. + defp sort_members_in_memory(members, field, order, custom_fields) do + custom_field_id_str = extract_custom_field_id(field) + + case custom_field_id_str do + nil -> + members + + id_str -> + sort_members_by_custom_field(members, id_str, order, custom_fields) + end + end + + # Sorts members by a specific custom field ID + defp sort_members_by_custom_field(members, id_str, order, custom_fields) do + custom_field = find_custom_field_by_id(custom_fields, id_str) + + case custom_field do + nil -> + members + + cf -> + sort_members_with_custom_field(members, cf, order) + end + end + + # Finds a custom field by matching its ID string + defp find_custom_field_by_id(custom_fields, id_str) do + Enum.find(custom_fields, fn cf -> + to_string(cf.id) == id_str + end) + end + + # Sorts members that have a specific custom field + defp sort_members_with_custom_field(members, custom_field, order) do + # Split members into those with values and those without (NULL/empty) + {members_with_values, members_without_values} = + split_members_by_value_presence(members, custom_field) + + # Sort members with values + sorted_with_values = sort_members_with_values(members_with_values, custom_field, order) + + # Combine: sorted values first, then NULL/empty values at the end + sorted_with_values ++ members_without_values + end + + # Splits members into those with values and those without + defp split_members_by_value_presence(members, custom_field) do + Enum.split_with(members, fn member -> + has_non_empty_value?(member, custom_field) + end) + end + + # Checks if a member has a non-empty value for the custom field + defp has_non_empty_value?(member, custom_field) do + case get_custom_field_value(member, custom_field) do + nil -> + false + + cfv -> + extracted = extract_sort_value(cfv.value, custom_field.value_type) + not empty_value?(extracted, custom_field.value_type) + end + end + + # Sorts members that have values for the custom field + defp sort_members_with_values(members_with_values, custom_field, order) do + sorted = + Enum.sort_by(members_with_values, fn member -> + cfv = get_custom_field_value(member, custom_field) + extracted = extract_sort_value(cfv.value, custom_field.value_type) + normalize_sort_value(extracted, order) + end) + + # For DESC, reverse only the members with values + if order == :desc do + Enum.reverse(sorted) + else + sorted + end + end + + # Extracts a sortable value from a custom field value based on its type. + # + # Handles different value formats: + # - `%Ash.Union{}` - Extracts value and type from union + # - Direct values - Returns as-is for primitive types + # + # Returns the extracted value suitable for sorting. + defp extract_sort_value(%Ash.Union{value: value, type: type}, _expected_type) do + extract_sort_value(value, type) + end + + defp extract_sort_value(value, :string) when is_binary(value), do: value + defp extract_sort_value(value, :integer) when is_integer(value), do: value + defp extract_sort_value(value, :boolean) when is_boolean(value), do: value + defp extract_sort_value(%Date{} = date, :date), do: date + defp extract_sort_value(value, :email) when is_binary(value), do: value + defp extract_sort_value(value, _type), do: to_string(value) + + # Check if a value is considered empty (NULL or empty string) + defp empty_value?(value, :string) when is_binary(value) do + String.trim(value) == "" + end + + defp empty_value?(value, :email) when is_binary(value) do + String.trim(value) == "" + end + + defp empty_value?(_value, _type), do: false + + # Normalize sort value for DESC order + # For DESC, we sort ascending first, then reverse the list + # This function is kept for consistency but doesn't need to invert values + defp normalize_sort_value(value, _order), do: value + + # Updates sort field and order from URL parameters if present. + # + # Validates the sort field and order, falling back to defaults if invalid. defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do field = determine_field(socket.assigns.sort_field, sf) order = determine_order(socket.assigns.sort_order, so) @@ -305,33 +639,50 @@ defmodule MvWeb.MemberLive.Index do defp maybe_update_sort(socket, _), do: socket - defp determine_field(default, sf) do - case sf do - "" -> - default + # Determine sort field from URL parameter, validating against allowed fields + defp determine_field(default, ""), do: default + defp determine_field(default, nil), do: default - nil -> - default - - sf when is_binary(sf) -> - sf - |> String.to_existing_atom() - |> handle_atom_conversion(default) - - sf when is_atom(sf) -> - handle_atom_conversion(sf, default) - - _ -> - default + # Determines the valid sort field from a URL parameter. + # + # Validates the field against allowed sort fields (regular member fields or custom fields). + # Falls back to default if the field is invalid. + # + # Parameters: + # - `default` - Default field to use if validation fails + # - `sf` - Sort field from URL (can be atom, string, nil, or empty string) + # + # Returns a valid sort field (atom or string for custom fields). + defp determine_field(default, sf) when is_binary(sf) do + # Check if it's a custom field sort (starts with "custom_field_") + if custom_field_sort?(sf) do + if valid_sort_field?(sf), do: sf, else: default + else + # Try to convert to atom for regular fields + try do + atom = String.to_existing_atom(sf) + if valid_sort_field?(atom), do: atom, else: default + rescue + ArgumentError -> default + end end end - defp handle_atom_conversion(val, default) when is_atom(val) do - if valid_sort_field?(val), do: val, else: default + defp determine_field(default, sf) when is_atom(sf) do + if valid_sort_field?(sf), do: sf, else: default end - defp handle_atom_conversion(_, default), do: default + defp determine_field(default, _), do: default + # Determines the valid sort order from a URL parameter. + # + # Validates that the order is either "asc" or "desc", falling back to default if invalid. + # + # Parameters: + # - `default` - Default order to use if validation fails + # - `so` - Sort order from URL (string, atom, nil, or empty string) + # + # Returns `:asc` or `:desc`. defp determine_order(default, so) do case so do "" -> default @@ -350,4 +701,36 @@ defmodule MvWeb.MemberLive.Index do # Keep the previous search query if no new one is provided socket end + + # ------------------------------------------------------------- + # Helper Functions for Custom Field Values + # ------------------------------------------------------------- + + # Retrieves the custom field value for a specific member and custom field. + # + # Searches through the member's `custom_field_values` relationship to find + # the value matching the given custom field. + # + # Returns: + # - `%CustomFieldValue{}` if found + # - `nil` if not found or if member has no custom field values + # + # Examples: + # get_custom_field_value(member, custom_field) -> %CustomFieldValue{...} + # get_custom_field_value(member, non_existent_field) -> nil + def get_custom_field_value(member, custom_field) do + case member.custom_field_values do + nil -> + nil + + values when is_list(values) -> + Enum.find(values, fn cfv -> + cfv.custom_field_id == custom_field.id or + (cfv.custom_field && cfv.custom_field.id == custom_field.id) + end) + + _ -> + nil + end + end end diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index cb2ccd8..67fa804 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -19,6 +19,9 @@ id="members" rows={@members} row_click={fn member -> JS.navigate(~p"/members/#{member}") end} + dynamic_cols={@dynamic_cols} + sort_field={@sort_field} + sort_order={@sort_order} > @@ -185,7 +188,6 @@ > {member.join_date} - <:action :let={member}>
<.link navigate={~p"/members/#{member}"}>{gettext("Show")} diff --git a/lib/mv_web/live/member_live/index/formatter.ex b/lib/mv_web/live/member_live/index/formatter.ex new file mode 100644 index 0000000..2074962 --- /dev/null +++ b/lib/mv_web/live/member_live/index/formatter.ex @@ -0,0 +1,74 @@ +defmodule MvWeb.MemberLive.Index.Formatter do + @moduledoc """ + Formats custom field values for display in the member overview table. + + Handles different value types (string, integer, boolean, date, email) and + formats them appropriately for display in the UI. + """ + use Gettext, backend: MvWeb.Gettext + + @doc """ + Formats a custom field value for display. + + Handles different input formats: + - `nil` - Returns empty string + - `%Ash.Union{}` - Extracts value and type from union type + - Map (JSONB format) - Extracts type and value from map keys + - Direct value - Uses custom_field.value_type to determine format + + ## Examples + + iex> format_custom_field_value(nil, %CustomField{value_type: :string}) + "" + + iex> format_custom_field_value("test", %CustomField{value_type: :string}) + "test" + + iex> format_custom_field_value(true, %CustomField{value_type: :boolean}) + "Yes" + """ + def format_custom_field_value(nil, _custom_field), do: "" + + def format_custom_field_value(%Ash.Union{value: value, type: type}, custom_field) do + format_value_by_type(value, type, custom_field) + end + + def format_custom_field_value(value, custom_field) when is_map(value) do + # Handle map format from JSONB + type = Map.get(value, "type") || Map.get(value, "_union_type") + val = Map.get(value, "value") || Map.get(value, "_union_value") + format_value_by_type(val, type, custom_field) + end + + def format_custom_field_value(value, custom_field) do + format_value_by_type(value, custom_field.value_type, custom_field) + end + + # Format value based on type + + defp format_value_by_type(value, :string, _), do: to_string(value) + + defp format_value_by_type(value, :integer, _), do: to_string(value) + + defp format_value_by_type(value, type, _) when type in [:string, :email] and is_binary(value) do + # Return empty string if value is empty + if String.trim(value) == "", do: "", else: value + end + + defp format_value_by_type(value, :email, _), do: to_string(value) + + defp format_value_by_type(value, :boolean, _) when value == true, do: gettext("Yes") + defp format_value_by_type(value, :boolean, _) when value == false, do: gettext("No") + defp format_value_by_type(value, :boolean, _), do: to_string(value) + + defp format_value_by_type(%Date{} = date, :date, _), do: Date.to_string(date) + + defp format_value_by_type(value, :date, _) when is_binary(value) do + case Date.from_iso8601(value) do + {:ok, date} -> Date.to_string(date) + _ -> value + end + end + + defp format_value_by_type(value, _type, _), do: to_string(value) +end diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 7b8c86e..27acc80 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -10,13 +10,13 @@ msgid "" msgstr "" "Language: en\n" -#: lib/mv_web/components/core_components.ex:339 +#: lib/mv_web/components/core_components.ex:356 #, elixir-autogen, elixir-format msgid "Actions" msgstr "Aktionen" -#: lib/mv_web/live/member_live/index.html.heex:200 -#: lib/mv_web/live/user_live/index.html.heex:72 +#: lib/mv_web/live/member_live/index.html.heex:202 +#: lib/mv_web/live/user_live/index.html.heex:65 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "Bist du sicher?" @@ -28,21 +28,21 @@ msgid "Attempting to reconnect" msgstr "Verbindung wird wiederhergestellt" #: lib/mv_web/live/member_live/form.ex:54 -#: lib/mv_web/live/member_live/index.html.heex:145 +#: lib/mv_web/live/member_live/index.html.heex:148 #: lib/mv_web/live/member_live/show.ex:59 #, elixir-autogen, elixir-format msgid "City" msgstr "Stadt" -#: lib/mv_web/live/member_live/index.html.heex:202 -#: lib/mv_web/live/user_live/index.html.heex:74 +#: lib/mv_web/live/member_live/index.html.heex:204 +#: lib/mv_web/live/user_live/index.html.heex:67 #, elixir-autogen, elixir-format msgid "Delete" msgstr "Löschen" -#: lib/mv_web/live/member_live/index.html.heex:194 -#: lib/mv_web/live/user_live/form.ex:251 -#: lib/mv_web/live/user_live/index.html.heex:66 +#: lib/mv_web/live/member_live/index.html.heex:196 +#: lib/mv_web/live/user_live/form.ex:141 +#: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format msgid "Edit" msgstr "Bearbeite" @@ -54,7 +54,7 @@ msgid "Edit Member" msgstr "Mitglied bearbeiten" #: lib/mv_web/live/member_live/form.ex:47 -#: lib/mv_web/live/member_live/index.html.heex:77 +#: lib/mv_web/live/member_live/index.html.heex:80 #: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/index.html.heex:44 @@ -70,7 +70,7 @@ msgid "First Name" msgstr "Vorname" #: lib/mv_web/live/member_live/form.ex:51 -#: lib/mv_web/live/member_live/index.html.heex:179 +#: lib/mv_web/live/member_live/index.html.heex:182 #: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" @@ -87,8 +87,8 @@ msgstr "Nachname" msgid "New Member" msgstr "Neues Mitglied" -#: lib/mv_web/live/member_live/index.html.heex:191 -#: lib/mv_web/live/user_live/index.html.heex:63 +#: lib/mv_web/live/member_live/index.html.heex:193 +#: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" msgstr "Anzeigen" @@ -121,7 +121,7 @@ msgid "Exit Date" msgstr "Austrittsdatum" #: lib/mv_web/live/member_live/form.ex:56 -#: lib/mv_web/live/member_live/index.html.heex:111 +#: lib/mv_web/live/member_live/index.html.heex:114 #: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "House Number" @@ -140,14 +140,14 @@ msgid "Paid" msgstr "Bezahlt" #: lib/mv_web/live/member_live/form.ex:50 -#: lib/mv_web/live/member_live/index.html.heex:162 +#: lib/mv_web/live/member_live/index.html.heex:165 #: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "Telefonnummer" #: lib/mv_web/live/member_live/form.ex:57 -#: lib/mv_web/live/member_live/index.html.heex:128 +#: lib/mv_web/live/member_live/index.html.heex:131 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -158,7 +158,7 @@ msgstr "Postleitzahl" msgid "Save Member" msgstr "Mitglied speichern" -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: lib/mv_web/live/custom_field_live/form.ex:66 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/user_live/form.ex:234 @@ -167,7 +167,7 @@ msgid "Saving..." msgstr "Speichern..." #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:94 +#: lib/mv_web/live/member_live/index.html.heex:97 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -183,6 +183,7 @@ msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenscha msgid "Id" msgstr "ID" +#: lib/mv_web/live/member_live/index/formatter.ex:65 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "No" @@ -198,19 +199,20 @@ msgstr "Mitglied anzeigen" msgid "This is a member record from your database." msgstr "Dies ist ein Mitglied aus deiner Datenbank." +#: lib/mv_web/live/member_live/index/formatter.ex:64 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Yes" msgstr "Ja" -#: lib/mv_web/live/custom_field_live/form.ex:108 +#: lib/mv_web/live/custom_field_live/form.ex:110 #: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "erstellt" -#: lib/mv_web/live/custom_field_live/form.ex:109 +#: lib/mv_web/live/custom_field_live/form.ex:111 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -252,7 +254,7 @@ msgstr "Ihre E-Mail-Adresse wurde bestĂ€tigt" msgid "Your password has successfully been reset" msgstr "Ihr Passwort wurde erfolgreich zurĂŒckgesetzt" -#: lib/mv_web/live/custom_field_live/form.ex:67 +#: lib/mv_web/live/custom_field_live/form.ex:69 #: lib/mv_web/live/custom_field_live/index.ex:120 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 @@ -266,7 +268,7 @@ msgstr "Abbrechen" msgid "Choose a member" msgstr "Mitglied auswĂ€hlen" -#: lib/mv_web/live/custom_field_live/form.ex:60 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Description" msgstr "Beschreibung" @@ -286,7 +288,7 @@ msgstr "Aktiviert" msgid "ID" msgstr "ID" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "UnverĂ€nderlich" @@ -308,13 +310,13 @@ msgid "Member" msgstr "Mitglied" #: lib/mv_web/components/layouts/navbar.ex:19 -#: lib/mv_web/live/member_live/index.ex:39 +#: lib/mv_web/live/member_live/index.ex:57 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "Mitglieder" -#: lib/mv_web/live/custom_field_live/form.ex:50 +#: lib/mv_web/live/custom_field_live/form.ex:51 #, elixir-autogen, elixir-format msgid "Name" msgstr "Name" @@ -357,17 +359,17 @@ msgstr "Passwort-Authentifizierung" msgid "Profil" msgstr "Profil" -#: lib/mv_web/live/custom_field_live/form.ex:62 +#: lib/mv_web/live/custom_field_live/form.ex:63 #, elixir-autogen, elixir-format msgid "Required" msgstr "Erforderlich" -#: lib/mv_web/live/member_live/index.html.heex:34 +#: lib/mv_web/live/member_live/index.html.heex:37 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "Alle Mitglieder auswĂ€hlen" -#: lib/mv_web/live/member_live/index.html.heex:48 +#: lib/mv_web/live/member_live/index.html.heex:51 #, elixir-autogen, elixir-format msgid "Select member" msgstr "Mitglied auswĂ€hlen" @@ -413,7 +415,7 @@ msgstr "Benutzer*in" msgid "Value" msgstr "Wert" -#: lib/mv_web/live/custom_field_live/form.ex:55 +#: lib/mv_web/live/custom_field_live/form.ex:56 #, elixir-autogen, elixir-format msgid "Value type" msgstr "Wertetyp" @@ -569,7 +571,7 @@ msgstr "Benutzer*innen" msgid "Click to sort" msgstr "Klicke um zu sortieren" -#: lib/mv_web/live/member_live/index.html.heex:60 +#: lib/mv_web/live/member_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "First name" msgstr "Vorname" @@ -621,7 +623,7 @@ msgstr "Benutzerdefinierte Feldwerte" msgid "Custom field" msgstr "Benutzerdefiniertes Feld" -#: lib/mv_web/live/custom_field_live/form.ex:115 +#: lib/mv_web/live/custom_field_live/form.ex:117 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "Benutzerdefiniertes Feld erfolgreich %{action}" @@ -636,7 +638,7 @@ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}" msgid "Please select a custom field first" msgstr "Bitte wĂ€hle zuerst ein Benutzerdefiniertes Feld" -#: lib/mv_web/live/custom_field_live/form.ex:65 +#: lib/mv_web/live/custom_field_live/form.ex:67 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "Benutzerdefiniertes Feld speichern" @@ -646,7 +648,7 @@ msgstr "Benutzerdefiniertes Feld speichern" msgid "Save Custom field value" msgstr "Benutzerdefinierten Feldwert speichern" -#: lib/mv_web/live/custom_field_live/form.ex:45 +#: lib/mv_web/live/custom_field_live/form.ex:46 #, elixir-autogen, elixir-format msgid "Use this form to manage custom_field records in your database." msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenbank zu verwalten." @@ -747,3 +749,12 @@ msgstr "EntverknĂŒpfung geplant" #, elixir-autogen, elixir-format msgid "Failed to link member: %{error}" msgstr "" +#: lib/mv_web/live/custom_field_live/form.ex:64 +#, elixir-autogen, elixir-format +msgid "Show in overview" +msgstr "In der Mitglieder-Übersicht anzeigen" + +#~ #: lib/mv_web/live/custom_field_live/index.ex:97 +#~ #, elixir-autogen, elixir-format +#~ msgid "To confirm deletion, please enter the custom field slug:" +#~ msgstr "Um die Löschung zu bestĂ€tigen, gib bitte den Slug des benutzerdefinierten Feldes ein:" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index a1ae484..7cf507b 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -11,13 +11,13 @@ msgid "" msgstr "" -#: lib/mv_web/components/core_components.ex:339 +#: lib/mv_web/components/core_components.ex:356 #, elixir-autogen, elixir-format msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:200 -#: lib/mv_web/live/user_live/index.html.heex:72 +#: lib/mv_web/live/member_live/index.html.heex:202 +#: lib/mv_web/live/user_live/index.html.heex:65 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "" @@ -29,21 +29,21 @@ msgid "Attempting to reconnect" msgstr "" #: lib/mv_web/live/member_live/form.ex:54 -#: lib/mv_web/live/member_live/index.html.heex:145 +#: lib/mv_web/live/member_live/index.html.heex:148 #: lib/mv_web/live/member_live/show.ex:59 #, elixir-autogen, elixir-format msgid "City" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:202 -#: lib/mv_web/live/user_live/index.html.heex:74 +#: lib/mv_web/live/member_live/index.html.heex:204 +#: lib/mv_web/live/user_live/index.html.heex:67 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:194 -#: lib/mv_web/live/user_live/form.ex:251 -#: lib/mv_web/live/user_live/index.html.heex:66 +#: lib/mv_web/live/member_live/index.html.heex:196 +#: lib/mv_web/live/user_live/form.ex:141 +#: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format msgid "Edit" msgstr "" @@ -55,7 +55,7 @@ msgid "Edit Member" msgstr "" #: lib/mv_web/live/member_live/form.ex:47 -#: lib/mv_web/live/member_live/index.html.heex:77 +#: lib/mv_web/live/member_live/index.html.heex:80 #: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/index.html.heex:44 @@ -71,7 +71,7 @@ msgid "First Name" msgstr "" #: lib/mv_web/live/member_live/form.ex:51 -#: lib/mv_web/live/member_live/index.html.heex:179 +#: lib/mv_web/live/member_live/index.html.heex:182 #: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" @@ -88,8 +88,8 @@ msgstr "" msgid "New Member" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:191 -#: lib/mv_web/live/user_live/index.html.heex:63 +#: lib/mv_web/live/member_live/index.html.heex:193 +#: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" msgstr "" @@ -122,7 +122,7 @@ msgid "Exit Date" msgstr "" #: lib/mv_web/live/member_live/form.ex:56 -#: lib/mv_web/live/member_live/index.html.heex:111 +#: lib/mv_web/live/member_live/index.html.heex:114 #: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "House Number" @@ -141,14 +141,14 @@ msgid "Paid" msgstr "" #: lib/mv_web/live/member_live/form.ex:50 -#: lib/mv_web/live/member_live/index.html.heex:162 +#: lib/mv_web/live/member_live/index.html.heex:165 #: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "" #: lib/mv_web/live/member_live/form.ex:57 -#: lib/mv_web/live/member_live/index.html.heex:128 +#: lib/mv_web/live/member_live/index.html.heex:131 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -159,7 +159,7 @@ msgstr "" msgid "Save Member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: lib/mv_web/live/custom_field_live/form.ex:66 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/user_live/form.ex:234 @@ -168,7 +168,7 @@ msgid "Saving..." msgstr "" #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:94 +#: lib/mv_web/live/member_live/index.html.heex:97 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -184,6 +184,7 @@ msgstr "" msgid "Id" msgstr "" +#: lib/mv_web/live/member_live/index/formatter.ex:65 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "No" @@ -199,19 +200,20 @@ msgstr "" msgid "This is a member record from your database." msgstr "" +#: lib/mv_web/live/member_live/index/formatter.ex:64 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Yes" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:108 +#: lib/mv_web/live/custom_field_live/form.ex:110 #: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:109 +#: lib/mv_web/live/custom_field_live/form.ex:111 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -253,7 +255,7 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:67 +#: lib/mv_web/live/custom_field_live/form.ex:69 #: lib/mv_web/live/custom_field_live/index.ex:120 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 @@ -267,7 +269,7 @@ msgstr "" msgid "Choose a member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:60 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Description" msgstr "" @@ -287,7 +289,7 @@ msgstr "" msgid "ID" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" @@ -309,13 +311,13 @@ msgid "Member" msgstr "" #: lib/mv_web/components/layouts/navbar.ex:19 -#: lib/mv_web/live/member_live/index.ex:39 +#: lib/mv_web/live/member_live/index.ex:57 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:50 +#: lib/mv_web/live/custom_field_live/form.ex:51 #, elixir-autogen, elixir-format msgid "Name" msgstr "" @@ -358,17 +360,17 @@ msgstr "" msgid "Profil" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:62 +#: lib/mv_web/live/custom_field_live/form.ex:63 #, elixir-autogen, elixir-format msgid "Required" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:34 +#: lib/mv_web/live/member_live/index.html.heex:37 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:48 +#: lib/mv_web/live/member_live/index.html.heex:51 #, elixir-autogen, elixir-format msgid "Select member" msgstr "" @@ -414,7 +416,7 @@ msgstr "" msgid "Value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:55 +#: lib/mv_web/live/custom_field_live/form.ex:56 #, elixir-autogen, elixir-format msgid "Value type" msgstr "" @@ -570,7 +572,7 @@ msgstr "" msgid "Click to sort" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:60 +#: lib/mv_web/live/member_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "First name" msgstr "" @@ -622,7 +624,7 @@ msgstr "" msgid "Custom field" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:115 +#: lib/mv_web/live/custom_field_live/form.ex:117 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "" @@ -637,7 +639,7 @@ msgstr "" msgid "Please select a custom field first" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:65 +#: lib/mv_web/live/custom_field_live/form.ex:67 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "" @@ -647,7 +649,7 @@ msgstr "" msgid "Save Custom field value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:45 +#: lib/mv_web/live/custom_field_live/form.ex:46 #, elixir-autogen, elixir-format msgid "Use this form to manage custom_field records in your database." msgstr "" @@ -699,52 +701,7 @@ msgstr "" msgid "To confirm deletion, please enter this text:" msgstr "" -#: lib/mv_web/live/user_live/form.ex:210 +#: lib/mv_web/live/custom_field_live/form.ex:64 #, elixir-autogen, elixir-format -msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:185 -#, elixir-autogen, elixir-format -msgid "Available members" -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:152 -#, elixir-autogen, elixir-format -msgid "Member will be unlinked when you save. Cannot select new member until saved." -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:226 -#, elixir-autogen, elixir-format -msgid "Save to confirm linking." -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:169 -#, elixir-autogen, elixir-format -msgid "Search for a member to link..." -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:173 -#, elixir-autogen, elixir-format -msgid "Search for member to link" -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:223 -#, elixir-autogen, elixir-format -msgid "Selected" -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:143 -#, elixir-autogen, elixir-format -msgid "Unlink Member" -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:152 -#, elixir-autogen, elixir-format -msgid "Unlinking scheduled" -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:342 -#, elixir-autogen, elixir-format -msgid "Failed to link member: %{error}" +msgid "Show in overview" msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 28339fc..ed38b0e 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -11,13 +11,13 @@ msgstr "" "Language: en\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: lib/mv_web/components/core_components.ex:339 +#: lib/mv_web/components/core_components.ex:356 #, elixir-autogen, elixir-format msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:200 -#: lib/mv_web/live/user_live/index.html.heex:72 +#: lib/mv_web/live/member_live/index.html.heex:202 +#: lib/mv_web/live/user_live/index.html.heex:65 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "" @@ -29,21 +29,21 @@ msgid "Attempting to reconnect" msgstr "" #: lib/mv_web/live/member_live/form.ex:54 -#: lib/mv_web/live/member_live/index.html.heex:145 +#: lib/mv_web/live/member_live/index.html.heex:148 #: lib/mv_web/live/member_live/show.ex:59 #, elixir-autogen, elixir-format msgid "City" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:202 -#: lib/mv_web/live/user_live/index.html.heex:74 +#: lib/mv_web/live/member_live/index.html.heex:204 +#: lib/mv_web/live/user_live/index.html.heex:67 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:194 -#: lib/mv_web/live/user_live/form.ex:251 -#: lib/mv_web/live/user_live/index.html.heex:66 +#: lib/mv_web/live/member_live/index.html.heex:196 +#: lib/mv_web/live/user_live/form.ex:141 +#: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format msgid "Edit" msgstr "" @@ -55,7 +55,7 @@ msgid "Edit Member" msgstr "" #: lib/mv_web/live/member_live/form.ex:47 -#: lib/mv_web/live/member_live/index.html.heex:77 +#: lib/mv_web/live/member_live/index.html.heex:80 #: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/index.html.heex:44 @@ -71,7 +71,7 @@ msgid "First Name" msgstr "" #: lib/mv_web/live/member_live/form.ex:51 -#: lib/mv_web/live/member_live/index.html.heex:179 +#: lib/mv_web/live/member_live/index.html.heex:182 #: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" @@ -88,8 +88,8 @@ msgstr "" msgid "New Member" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:191 -#: lib/mv_web/live/user_live/index.html.heex:63 +#: lib/mv_web/live/member_live/index.html.heex:193 +#: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" msgstr "" @@ -122,7 +122,7 @@ msgid "Exit Date" msgstr "" #: lib/mv_web/live/member_live/form.ex:56 -#: lib/mv_web/live/member_live/index.html.heex:111 +#: lib/mv_web/live/member_live/index.html.heex:114 #: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "House Number" @@ -141,14 +141,14 @@ msgid "Paid" msgstr "" #: lib/mv_web/live/member_live/form.ex:50 -#: lib/mv_web/live/member_live/index.html.heex:162 +#: lib/mv_web/live/member_live/index.html.heex:165 #: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "" #: lib/mv_web/live/member_live/form.ex:57 -#: lib/mv_web/live/member_live/index.html.heex:128 +#: lib/mv_web/live/member_live/index.html.heex:131 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -159,7 +159,7 @@ msgstr "" msgid "Save Member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: lib/mv_web/live/custom_field_live/form.ex:66 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/user_live/form.ex:234 @@ -168,7 +168,7 @@ msgid "Saving..." msgstr "" #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:94 +#: lib/mv_web/live/member_live/index.html.heex:97 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -184,6 +184,7 @@ msgstr "" msgid "Id" msgstr "" +#: lib/mv_web/live/member_live/index/formatter.ex:65 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "No" @@ -199,19 +200,20 @@ msgstr "" msgid "This is a member record from your database." msgstr "" +#: lib/mv_web/live/member_live/index/formatter.ex:64 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Yes" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:108 +#: lib/mv_web/live/custom_field_live/form.ex:110 #: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:109 +#: lib/mv_web/live/custom_field_live/form.ex:111 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -253,7 +255,7 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:67 +#: lib/mv_web/live/custom_field_live/form.ex:69 #: lib/mv_web/live/custom_field_live/index.ex:120 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 @@ -267,7 +269,7 @@ msgstr "" msgid "Choose a member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:60 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Description" msgstr "" @@ -287,7 +289,7 @@ msgstr "" msgid "ID" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" @@ -309,13 +311,13 @@ msgid "Member" msgstr "" #: lib/mv_web/components/layouts/navbar.ex:19 -#: lib/mv_web/live/member_live/index.ex:39 +#: lib/mv_web/live/member_live/index.ex:57 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:50 +#: lib/mv_web/live/custom_field_live/form.ex:51 #, elixir-autogen, elixir-format msgid "Name" msgstr "" @@ -358,17 +360,17 @@ msgstr "" msgid "Profil" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:62 +#: lib/mv_web/live/custom_field_live/form.ex:63 #, elixir-autogen, elixir-format msgid "Required" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:34 +#: lib/mv_web/live/member_live/index.html.heex:37 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:48 +#: lib/mv_web/live/member_live/index.html.heex:51 #, elixir-autogen, elixir-format msgid "Select member" msgstr "" @@ -414,7 +416,7 @@ msgstr "" msgid "Value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:55 +#: lib/mv_web/live/custom_field_live/form.ex:56 #, elixir-autogen, elixir-format msgid "Value type" msgstr "" @@ -570,7 +572,7 @@ msgstr "" msgid "Click to sort" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:60 +#: lib/mv_web/live/member_live/index.html.heex:63 #, elixir-autogen, elixir-format, fuzzy msgid "First name" msgstr "" @@ -622,7 +624,7 @@ msgstr "" msgid "Custom field" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:115 +#: lib/mv_web/live/custom_field_live/form.ex:117 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "" @@ -637,7 +639,7 @@ msgstr "" msgid "Please select a custom field first" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:65 +#: lib/mv_web/live/custom_field_live/form.ex:67 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "" @@ -647,7 +649,7 @@ msgstr "" msgid "Save Custom field value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:45 +#: lib/mv_web/live/custom_field_live/form.ex:46 #, elixir-autogen, elixir-format, fuzzy msgid "Use this form to manage custom_field records in your database." msgstr "" @@ -748,3 +750,12 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Failed to link member: %{error}" msgstr "" +#: lib/mv_web/live/custom_field_live/form.ex:64 +#, elixir-autogen, elixir-format +msgid "Show in overview" +msgstr "" + +#~ #: lib/mv_web/live/custom_field_live/index.ex:97 +#~ #, elixir-autogen, elixir-format +#~ msgid "To confirm deletion, please enter the custom field slug:" +#~ msgstr "" diff --git a/priv/repo/migrations/20251119160509_add_show_in_overview_to_custom_fields.exs b/priv/repo/migrations/20251119160509_add_show_in_overview_to_custom_fields.exs new file mode 100644 index 0000000..32b4801 --- /dev/null +++ b/priv/repo/migrations/20251119160509_add_show_in_overview_to_custom_fields.exs @@ -0,0 +1,21 @@ +defmodule Mv.Repo.Migrations.AddShowInOverviewToCustomFields do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:custom_fields) do + add :show_in_overview, :boolean, null: false, default: true + end + end + + def down do + alter table(:custom_fields) do + remove :show_in_overview + end + end +end diff --git a/priv/resource_snapshots/repo/custom_fields/20251119160509.json b/priv/resource_snapshots/repo/custom_fields/20251119160509.json new file mode 100644 index 0000000..718fe51 --- /dev/null +++ b/priv/resource_snapshots/repo/custom_fields/20251119160509.json @@ -0,0 +1,118 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "value_type", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "immutable", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "required", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "show_in_overview", + "type": "boolean" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "9FBFC42DA896058F88DEDAE774614919222BF2EF2F8CB27386D02C2CE67F03DE", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "custom_fields_unique_name_index", + "keys": [ + { + "type": "atom", + "value": "name" + } + ], + "name": "unique_name", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "custom_fields" +} \ No newline at end of file diff --git a/test/membership/custom_field_show_in_overview_test.exs b/test/membership/custom_field_show_in_overview_test.exs new file mode 100644 index 0000000..adac600 --- /dev/null +++ b/test/membership/custom_field_show_in_overview_test.exs @@ -0,0 +1,77 @@ +defmodule Mv.Membership.CustomFieldShowInOverviewTest do + @moduledoc """ + Tests for CustomField show_in_overview attribute. + + Tests cover: + - Creating custom fields with show_in_overview: true + - Creating custom fields with show_in_overview: false (default) + - Updating show_in_overview to true + - Updating show_in_overview to false + """ + use Mv.DataCase, async: true + + alias Mv.Membership.CustomField + + describe "show_in_overview attribute" do + test "creates custom field with show_in_overview: true" do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field_show", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + assert custom_field.show_in_overview == true + end + + test "creates custom field with show_in_overview: true (default)" do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field_hide", + value_type: :string + }) + |> Ash.create() + + assert custom_field.show_in_overview == true + end + + test "updates show_in_overview to true" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field_update", + value_type: :string, + show_in_overview: false + }) + |> Ash.create() + + assert {:ok, updated_field} = + custom_field + |> Ash.Changeset.for_update(:update, %{show_in_overview: true}) + |> Ash.update() + + assert updated_field.show_in_overview == true + end + + test "updates show_in_overview to false" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field_update2", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + assert {:ok, updated_field} = + custom_field + |> Ash.Changeset.for_update(:update, %{show_in_overview: false}) + |> Ash.update() + + assert updated_field.show_in_overview == false + end + end +end diff --git a/test/mv_web/member_live/index_custom_fields_accessibility_test.exs b/test/mv_web/member_live/index_custom_fields_accessibility_test.exs new file mode 100644 index 0000000..cfe3145 --- /dev/null +++ b/test/mv_web/member_live/index_custom_fields_accessibility_test.exs @@ -0,0 +1,113 @@ +defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do + @moduledoc """ + Accessibility tests for custom field columns in the member overview. + + Tests cover: + - SortHeaderComponent for custom fields has correct ARIA labels + - Tab navigation works for custom field columns + - Screen reader announcements for sorting + """ + use MvWeb.ConnCase, async: true + import Phoenix.LiveViewTest + require Ash.Query + + alias Mv.Membership.{CustomField, CustomFieldValue, Member} + + setup do + # Create test member + {:ok, member} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + |> Ash.create() + + # Create custom field with show_in_overview: true + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "membership_number", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create custom field value + {:ok, _cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "A001"} + }) + |> Ash.create() + + %{member: member, field: field} + end + + test "sort header component for custom fields has correct ARIA labels", %{ + conn: conn, + field: field + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the sort button has aria-label + assert html =~ ~r/aria-label=["']Click to sort["']/i or + html =~ ~r/aria-label=["'].*sort.*["']/i + + # Check that data-testid is present for testing + assert html =~ ~r/data-testid=["']custom_field_#{field.id}["']/ + end + + test "sort header component shows correct ARIA label when sorted ascending", %{ + conn: conn, + field: field + } do + conn = conn_with_oidc_user(conn) + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + + html = render(view) + + # Check that aria-label indicates ascending sort + assert html =~ ~r/aria-label=["'].*ascending.*["']/i + end + + test "sort header component shows correct ARIA label when sorted descending", %{ + conn: conn, + field: field + } do + conn = conn_with_oidc_user(conn) + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + html = render(view) + + # Check that aria-label indicates descending sort + assert html =~ ~r/aria-label=["'].*descending.*["']/i + end + + test "custom field column header is keyboard accessible", %{conn: conn, field: field} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the sort button is a button element (keyboard accessible) + assert html =~ ~r/]*data-testid=["']custom_field_#{field.id}["']/ + + # Button should not have tabindex="-1" (which would remove from tab order) + refute html =~ ~r/tabindex=["']-1["']/ + end + + test "custom field column header has proper semantic structure", %{conn: conn, field: field} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that custom field name is displayed in the header + assert html =~ field.name + 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 new file mode 100644 index 0000000..25aefe5 --- /dev/null +++ b/test/mv_web/member_live/index_custom_fields_display_test.exs @@ -0,0 +1,262 @@ +defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do + @moduledoc """ + Tests for displaying custom fields in the member overview. + + Tests cover: + - Custom fields with show_in_overview: true are displayed + - Custom fields with show_in_overview: false are not displayed + - Multiple custom fields with show_in_overview: true are all displayed + - Custom field values are correctly formatted for different types + - Members without custom field values show empty cell or "-" + """ + 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" + }) + |> Ash.create() + + {:ok, member2} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Bob", + last_name: "Brown", + email: "bob@example.com" + }) + |> Ash.create() + + # Create custom fields + {:ok, field_show_string} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "phone_mobile", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_hide} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "internal_note", + value_type: :string, + show_in_overview: false + }) + |> Ash.create() + + {:ok, field_show_integer} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "membership_number", + value_type: :integer, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_show_boolean} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "newsletter", + value_type: :boolean, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_show_date} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "birthday", + value_type: :date, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_show_email} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "secondary_email", + value_type: :email, + show_in_overview: true + }) + |> Ash.create() + + # Create custom field values for member1 + {:ok, _cfv1} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_string.id, + value: %{"_union_type" => "string", "_union_value" => "+49123456789"} + }) + |> Ash.create() + + {:ok, _cfv2} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_integer.id, + value: %{"_union_type" => "integer", "_union_value" => 12_345} + }) + |> Ash.create() + + {:ok, _cfv3} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_boolean.id, + value: %{"_union_type" => "boolean", "_union_value" => true} + }) + |> Ash.create() + + {:ok, _cfv4} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_date.id, + value: %{"_union_type" => "date", "_union_value" => ~D[1990-05-15]} + }) + |> Ash.create() + + {:ok, _cfv5} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_email.id, + value: %{"_union_type" => "email", "_union_value" => "alice.private@example.com"} + }) + |> Ash.create() + + # Create hidden custom field value (should not be displayed) + {:ok, _cfv_hidden} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_hide.id, + value: %{"_union_type" => "string", "_union_value" => "Internal note"} + }) + |> Ash.create() + + %{ + member1: member1, + member2: member2, + field_show_string: field_show_string, + field_hide: field_hide, + field_show_integer: field_show_integer, + field_show_boolean: field_show_boolean, + field_show_date: field_show_date, + field_show_email: field_show_email + } + end + + test "displays custom field with show_in_overview: true", %{ + conn: conn, + member1: _member1, + field_show_string: field + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the custom field column header is displayed + assert html =~ field.name + + # Check that the value is displayed + assert html =~ "+49123456789" + end + + test "does not display custom field with show_in_overview: false", %{ + conn: conn, + member1: _member1, + field_hide: field + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the hidden custom field column header is NOT displayed + refute html =~ field.name + + # Check that the value is NOT displayed + refute html =~ "Internal note" + end + + test "displays multiple custom fields with show_in_overview: true", %{ + conn: conn, + field_show_string: field_string, + field_show_integer: field_integer, + field_show_boolean: field_boolean + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that all visible custom field column headers are displayed + assert html =~ field_string.name + assert html =~ field_integer.name + assert html =~ field_boolean.name + end + + test "formats string custom field values correctly", %{conn: conn, member1: _member1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + assert html =~ "+49123456789" + end + + test "formats integer custom field values correctly", %{conn: conn, member1: _member1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + assert html =~ "12345" + end + + test "formats boolean custom field values correctly", %{conn: conn, member1: _member1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Boolean should be displayed as "Yes" or "No" or similar + # Check for true representation + assert html =~ "true" or html =~ "Yes" or html =~ "Ja" + end + + test "formats date custom field values correctly", %{conn: conn, member1: _member1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Date should be displayed in readable format + assert html =~ "1990" or html =~ "1990-05-15" or html =~ "15.05.1990" + end + + test "formats email custom field values correctly", %{conn: conn, member1: _member1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + assert html =~ "alice.private@example.com" + end + + test "shows empty cell or placeholder for members without custom field values", %{ + conn: conn, + member2: _member2, + field_show_string: field + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # The custom field column should exist + assert html =~ field.name + + # 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" + end +end diff --git a/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs b/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs new file mode 100644 index 0000000..d526556 --- /dev/null +++ b/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs @@ -0,0 +1,173 @@ +defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do + @moduledoc """ + Edge case tests for custom fields in the member overview. + + Tests cover: + - Custom field without values (all members have no value) + - Very long custom field values are correctly displayed + """ + use MvWeb.ConnCase, async: true + import Phoenix.LiveViewTest + require Ash.Query + + alias Mv.Membership.{CustomField, Member} + + test "displays custom field column even when no members have values", %{conn: conn} do + # Create test members without custom field values + {:ok, _member1} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + |> Ash.create() + + {:ok, _member2} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Bob", + last_name: "Brown", + email: "bob@example.com" + }) + |> Ash.create() + + # Create custom field with show_in_overview: true but no values + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "membership_number", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the custom field column header is still displayed + assert html =~ field.name + end + + test "displays very long custom field values correctly", %{conn: conn} do + # Create test member + {:ok, member} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + |> Ash.create() + + # Create custom field + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "long_note", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create very long value (but within limits) + long_value = String.duplicate("A", 500) + + {:ok, _cfv} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => long_value} + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the value is displayed (may be truncated in UI, but should be present) + # We check for at least part of the value + assert html =~ "A" or html =~ long_value + end + + test "handles multiple custom fields with show_in_overview correctly", %{conn: conn} do + # Create test member + {:ok, member} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + |> Ash.create() + + # Create multiple custom fields with show_in_overview: true + {:ok, field1} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "field1", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field2} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "field2", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field3} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "field3", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create values for all fields + {:ok, _cfv1} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field1.id, + value: %{"_union_type" => "string", "_union_value" => "Value1"} + }) + |> Ash.create() + + {:ok, _cfv2} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field2.id, + value: %{"_union_type" => "string", "_union_value" => "Value2"} + }) + |> Ash.create() + + {:ok, _cfv3} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field3.id, + value: %{"_union_type" => "string", "_union_value" => "Value3"} + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that all custom field columns are displayed + assert html =~ field1.name + assert html =~ field2.name + assert html =~ field3.name + + # Check that all values are displayed + assert html =~ "Value1" + assert html =~ "Value2" + assert html =~ "Value3" + end +end diff --git a/test/mv_web/member_live/index_custom_fields_sorting_test.exs b/test/mv_web/member_live/index_custom_fields_sorting_test.exs new file mode 100644 index 0000000..21b0c9f --- /dev/null +++ b/test/mv_web/member_live/index_custom_fields_sorting_test.exs @@ -0,0 +1,459 @@ +defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do + @moduledoc """ + Tests for sorting by custom fields in the member overview. + + Tests cover: + - Sorting by custom field (ascending) + - Sorting by custom field (descending) + - Sorting by custom field works with search + - Sorting by custom field works with URL parameters + - Sorting by custom field works with other columns + """ + 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" + }) + |> Ash.create() + + {:ok, member2} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Bob", + last_name: "Brown", + email: "bob@example.com" + }) + |> Ash.create() + + {:ok, member3} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Charlie", + last_name: "Clark", + email: "charlie@example.com" + }) + |> Ash.create() + + # Create custom field with show_in_overview: true + {:ok, field_string} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "membership_number", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_integer} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "priority", + value_type: :integer, + 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: field_string.id, + value: %{"_union_type" => "string", "_union_value" => "A001"} + }) + |> Ash.create() + + {:ok, _cfv2} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member2.id, + custom_field_id: field_string.id, + value: %{"_union_type" => "string", "_union_value" => "C003"} + }) + |> Ash.create() + + {:ok, _cfv3} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member3.id, + custom_field_id: field_string.id, + value: %{"_union_type" => "string", "_union_value" => "B002"} + }) + |> Ash.create() + + {:ok, _cfv4} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_integer.id, + value: %{"_union_type" => "integer", "_union_value" => 10} + }) + |> Ash.create() + + {:ok, _cfv5} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member2.id, + custom_field_id: field_integer.id, + value: %{"_union_type" => "integer", "_union_value" => 30} + }) + |> Ash.create() + + {:ok, _cfv6} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member3.id, + custom_field_id: field_integer.id, + value: %{"_union_type" => "integer", "_union_value" => 20} + }) + |> Ash.create() + + %{ + member1: member1, + member2: member2, + member3: member3, + field_string: field_string, + field_integer: field_integer + } + end + + test "sorts by custom field ascending", %{conn: conn, field_string: field} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Click on custom field column header to sort + view + |> element("[data-testid='custom_field_#{field.id}']") + |> render_click() + + # Check URL was updated + assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + + # Verify sort state + assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='ascending']") + end + + test "sorts by custom field descending", %{conn: conn, field_string: field} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?sort_field=custom_field_#{field.id}&sort_order=asc") + + # Click again to toggle to descending + view + |> element("[data-testid='custom_field_#{field.id}']") + |> render_click() + + # Check URL was updated + assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + # Verify sort state + assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='descending']") + end + + test "sorting by custom field works with search", %{conn: conn, field_string: field} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=Alice") + + # Click on custom field column header to sort + view + |> element("[data-testid='custom_field_#{field.id}']") + |> render_click() + + # Check URL maintains search query + assert_patch(view, "/members?query=Alice&sort_field=custom_field_#{field.id}&sort_order=asc") + end + + test "sorting by custom field works with URL parameters", %{conn: conn, field_string: field} do + conn = conn_with_oidc_user(conn) + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + # Check that the sort state is correctly applied + assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='descending']") + end + + test "clicking different custom field column resets order to ascending", %{ + conn: conn, + field_string: field_string, + field_integer: field_integer + } do + conn = conn_with_oidc_user(conn) + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field_string.id}&sort_order=desc") + + # Click on a different custom field column + view + |> element("[data-testid='custom_field_#{field_integer.id}']") + |> render_click() + + assert_patch( + view, + "/members?query=&sort_field=custom_field_#{field_integer.id}&sort_order=asc" + ) + end + + test "clicking regular column after custom field column works", %{ + conn: conn, + field_string: field + } do + conn = conn_with_oidc_user(conn) + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + # Click on email column + view + |> element("[data-testid='email']") + |> render_click() + + assert_patch(view, "/members?query=&sort_field=email&sort_order=asc") + end + + test "clicking custom field column after regular column works", %{ + conn: conn, + field_string: field + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc") + + # Click on custom field column + view + |> element("[data-testid='custom_field_#{field.id}']") + |> render_click() + + assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + end + + test "NULL values and empty strings are always sorted last (ASC)", %{conn: conn} do + # Create additional members with NULL and empty string values + {:ok, member_with_value} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithValue", + last_name: "Test", + email: "withvalue@example.com" + }) + |> Ash.create() + + {:ok, member_with_empty} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithEmpty", + last_name: "Test", + email: "withempty@example.com" + }) + |> Ash.create() + + {:ok, member_with_null} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithNull", + last_name: "Test", + email: "withnull@example.com" + }) + |> Ash.create() + + {:ok, member_with_another_value} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "AnotherValue", + last_name: "Test", + email: "another@example.com" + }) + |> Ash.create() + + # Create custom field + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create values: one with actual value, one with empty string, one with NULL (no value), another with value + {:ok, _cfv1} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_value.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "Zebra"} + }) + |> Ash.create() + + {:ok, _cfv2} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_empty.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => ""} + }) + |> Ash.create() + + # member_with_null has no custom field value (NULL) + + {:ok, _cfv3} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_another_value.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "Apple"} + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + + html = render(view) + + # Find positions of member first names in the HTML to verify sort order + apple_pos = :binary.match(html, member_with_another_value.first_name) + zebra_pos = :binary.match(html, member_with_value.first_name) + empty_pos = :binary.match(html, member_with_empty.first_name) + null_pos = :binary.match(html, member_with_null.first_name) + + assert apple_pos != :nomatch, "AnotherValue (Apple) should be in HTML" + assert zebra_pos != :nomatch, "WithValue (Zebra) should be in HTML" + assert empty_pos != :nomatch, "WithEmpty should be in HTML" + assert null_pos != :nomatch, "WithNull should be in HTML" + + {apple_idx, _} = apple_pos + {zebra_idx, _} = zebra_pos + {empty_idx, _} = empty_pos + {null_idx, _} = null_pos + + # In ASC order: Apple should come before Zebra + assert apple_idx < zebra_idx, "Apple should come before Zebra in ASC order" + + # NULL and empty should come after all values + assert apple_idx < empty_idx, "Apple should come before empty value" + assert apple_idx < null_idx, "Apple should come before NULL value" + assert zebra_idx < empty_idx, "Zebra should come before empty value" + assert zebra_idx < null_idx, "Zebra should come before NULL value" + end + + test "NULL values and empty strings are always sorted last (DESC)", %{conn: conn} do + # Create additional members with NULL and empty string values + {:ok, member_with_value} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithValue", + last_name: "Test", + email: "withvalue@example.com" + }) + |> Ash.create() + + {:ok, member_with_empty} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithEmpty", + last_name: "Test", + email: "withempty@example.com" + }) + |> Ash.create() + + {:ok, member_with_null} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithNull", + last_name: "Test", + email: "withnull@example.com" + }) + |> Ash.create() + + {:ok, member_with_another_value} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "AnotherValue", + last_name: "Test", + email: "another@example.com" + }) + |> Ash.create() + + # Create custom field + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create values: one with actual value, one with empty string, one with NULL (no value), another with value + {:ok, _cfv1} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_value.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "Apple"} + }) + |> Ash.create() + + {:ok, _cfv2} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_empty.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => ""} + }) + |> Ash.create() + + # member_with_null has no custom field value (NULL) + + {:ok, _cfv3} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_another_value.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "Zebra"} + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + html = render(view) + + # Find positions of member first names in the HTML to verify sort order + apple_pos = :binary.match(html, member_with_value.first_name) + zebra_pos = :binary.match(html, member_with_another_value.first_name) + empty_pos = :binary.match(html, member_with_empty.first_name) + null_pos = :binary.match(html, member_with_null.first_name) + + assert apple_pos != :nomatch, "WithValue (Apple) should be in HTML" + assert zebra_pos != :nomatch, "AnotherValue (Zebra) should be in HTML" + assert empty_pos != :nomatch, "WithEmpty should be in HTML" + assert null_pos != :nomatch, "WithNull should be in HTML" + + {apple_idx, _} = apple_pos + {zebra_idx, _} = zebra_pos + {empty_idx, _} = empty_pos + {null_idx, _} = null_pos + + # In DESC order: Zebra should come before Apple + assert zebra_idx < apple_idx, "Zebra should come before Apple in DESC order" + + # NULL and empty should come after all values + assert zebra_idx < empty_idx, "Zebra should come before empty value" + assert zebra_idx < null_idx, "Zebra should come before NULL value" + assert apple_idx < empty_idx, "Apple should come before empty value" + assert apple_idx < null_idx, "Apple should come before NULL value" + end +end