diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 74df997..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,19 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### Added -- User-Member linking with fuzzy search autocomplete (#168) -- PostgreSQL trigram-based member search with typo tolerance -- WCAG 2.1 AA compliant autocomplete dropdown with ARIA support -- Bilingual UI (German/English) for member linking workflow - -### Fixed -- Email validation false positive when linking user and member with identical emails (#168 Problem #4) -- Relationship data extraction from Ash manage_relationship during validation - diff --git a/assets/js/app.js b/assets/js/app.js index e55a06d..d5e278a 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -23,42 +23,9 @@ import {LiveSocket} from "phoenix_live_view" import topbar from "../vendor/topbar" let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") - -// Hooks for LiveView components -let Hooks = {} - -// ComboBox hook: Prevents form submission when Enter is pressed in dropdown -Hooks.ComboBox = { - mounted() { - this.handleKeyDown = (e) => { - const isDropdownOpen = this.el.getAttribute("aria-expanded") === "true" - - if (e.key === "Enter" && isDropdownOpen) { - e.preventDefault() - } - } - - this.el.addEventListener("keydown", this.handleKeyDown) - }, - - destroyed() { - this.el.removeEventListener("keydown", this.handleKeyDown) - } -} - let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, - params: {_csrf_token: csrfToken}, - hooks: Hooks -}) - -// Listen for custom events from LiveView -window.addEventListener("phx:set-input-value", (e) => { - const {id, value} = e.detail - const input = document.getElementById(id) - if (input) { - input.value = value - } + params: {_csrf_token: csrfToken} }) // Show progress bar on live navigation and form submits diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index 5669a19..f7447f2 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -329,11 +329,6 @@ 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 @@ -395,7 +390,6 @@ 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 @@ -1327,210 +1321,6 @@ end --- -## Session: User-Member Linking UI Enhancement (2025-01-13) - -### Feature Summary -Implemented user-member linking functionality in User Edit/Create views with fuzzy search autocomplete, email conflict handling, and accessibility support. - -**Key Features:** -- Autocomplete dropdown with PostgreSQL Trigram fuzzy search -- Keyboard navigation (Arrow keys, Enter, Escape) -- Link/unlink members to user accounts -- Email synchronization between linked entities -- WCAG 2.1 AA compliant (ARIA labels, keyboard accessibility) -- Bilingual UI (English/German) - -### Technical Decisions - -**1. Search Priority Logic** -Search query takes precedence over email filtering to provide better UX: -- User types → fuzzy search across all unlinked members -- Email matching only used for post-filtering when no search query present - -**2. JavaScript Hook for Input Value** -Used minimal JavaScript (~6 lines) for reliable input field updates: -```javascript -// assets/js/app.js -window.addEventListener("phx:set-input-value", (e) => { - document.getElementById(e.detail.id).value = e.detail.value -}) -``` -**Rationale:** LiveView DOM patching has race conditions with rapid state changes in autocomplete components. Direct DOM manipulation via `push_event` is the idiomatic LiveView solution for this edge case. - -**3. Keyboard Navigation: Hybrid Approach** -Implemented keyboard accessibility with **mostly Server-Side + minimal Client-Side**: - -```elixir -# Server-Side: Navigation and Selection (~45 lines) -def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do - # Focus management on server - new_index = min(current + 1, max_index) - {:noreply, assign(socket, focused_member_index: new_index)} -end -``` - -```javascript -// Client-Side: Only preventDefault for Enter in forms (~13 lines) -Hooks.ComboBox = { - mounted() { - this.el.addEventListener("keydown", (e) => { - const isDropdownOpen = this.el.getAttribute("aria-expanded") === "true" - if (e.key === "Enter" && isDropdownOpen) { - e.preventDefault() // Prevent form submission - } - }) - } -} -``` - -**Rationale:** -- Server-Side handles all navigation logic → simpler, testable, follows LiveView best practices -- Client-Side only prevents browser default behavior (form submit on Enter) -- Latency (~20-50ms) is imperceptible for keyboard events without DB queries -- Follows CODE_GUIDELINES "Minimal JavaScript Philosophy" - -**Alternative Considered:** Full Client-Side with JavaScript Hook (~80 lines) -- ❌ More complex code -- ❌ State synchronization between client/server -- ✅ Zero latency (but not noticeable in practice) -- **Decision:** Server-Side approach is simpler and sufficient - -**4. Fuzzy Search Implementation** -Combined PostgreSQL Full-Text Search + Trigram for optimal results: -```sql --- FTS for exact word matching -search_vector @@ websearch_to_tsquery('simple', 'greta') --- Trigram for typo tolerance -word_similarity('gre', first_name) > 0.2 --- Substring for email/IDs -email ILIKE '%greta%' -``` - -### Key Learnings - -#### 1. Ash `manage_relationship` Internals -**Critical Discovery:** During validation, relationship data lives in `changeset.relationships`, NOT `changeset.attributes`: - -```elixir -# During validation (manage_relationship processing): -changeset.relationships.member = [{[%{id: "uuid"}], opts}] -changeset.attributes.member_id = nil # Still nil! - -# After action completes: -changeset.attributes.member_id = "uuid" # Now set -``` - -**Solution:** Extract member_id from both sources: -```elixir -defp get_member_id_from_changeset(changeset) do - case Map.get(changeset.relationships, :member) do - [{[%{id: id}], _opts}] -> id # New link - _ -> Ash.Changeset.get_attribute(changeset, :member_id) # Existing - end -end -``` - -**Impact:** Fixed email validation false positives when linking user+member with identical emails. - -#### 2. LiveView + JavaScript Integration Patterns - -**When to use JavaScript:** -- ✅ Direct DOM manipulation (autocomplete, input values) -- ✅ Browser APIs (clipboard, geolocation) -- ✅ Third-party libraries -- ✅ Preventing browser default behaviors (form submit, scroll) - -**When NOT to use JavaScript:** -- ❌ Form submissions -- ❌ Simple show/hide logic -- ❌ Server-side data fetching -- ❌ Keyboard navigation logic (can be done server-side efficiently) - -**Pattern:** -```elixir -socket |> push_event("event-name", %{key: value}) -``` -```javascript -window.addEventListener("phx:event-name", (e) => { /* handle */ }) -``` - -**Keyboard Events Pattern:** -For keyboard navigation in forms, use hybrid approach: -- Server handles navigation logic via `phx-window-keydown` -- Minimal hook only for `preventDefault()` to avoid form submit conflicts -- Result: ~13 lines JS vs ~80 lines for full client-side solution - -#### 3. PostgreSQL Trigram Search -Requires `pg_trgm` extension with GIN indexes: -```sql -CREATE INDEX members_first_name_trgm_idx - ON members USING GIN(first_name gin_trgm_ops); -``` -Supports: -- Typo tolerance: "Gret" finds "Greta" -- Partial matching: "Mit" finds "Mitglied" -- Substring: "exam" finds "example.com" - -#### 4. Server-Side Keyboard Navigation Performance -**Challenge:** Concern that server-side keyboard events would feel laggy. - -**Reality Check:** -- LiveView roundtrip: ~20-50ms on decent connection -- Human perception threshold: ~100ms -- Result: **Feels instant** in practice - -**Why it works:** -```elixir -# Event handler only updates index (no DB queries) -def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do - new_index = min(socket.assigns.focused_member_index + 1, max_index) - {:noreply, assign(socket, focused_member_index: new_index)} -end -``` -- No database queries -- No complex computations -- Just state updates → extremely fast - -**When to use Client-Side instead:** -- Complex animations (Canvas, WebGL) -- Real-time gaming -- Continuous interactions (drag & drop, drawing) - -**Lesson:** Don't prematurely optimize for latency. Server-side is simpler and often sufficient. - -#### 5. Test-Driven Development for Bug Fixes -Effective workflow: -1. Write test that reproduces bug (should fail) -2. Implement minimal fix -3. Verify test passes -4. Refactor while green - -**Result:** 355 tests passing, 100% backend coverage for new features. - -### Files Changed - -**Backend:** -- `lib/membership/member.ex` - `:available_for_linking` action with fuzzy search -- `lib/mv/accounts/user/validations/email_not_used_by_other_member.ex` - Relationship change extraction -- `lib/mv_web/live/user_live/form.ex` - Event handlers, state management - -**Frontend:** -- `assets/js/app.js` - Input value hook (6 lines) + ComboBox hook (13 lines) -- `lib/mv_web/live/user_live/form.ex` - Keyboard event handlers, focus management -- `priv/gettext/**/*.po` - 10 new translation keys (DE/EN) - -**Tests (NEW):** -- `test/membership/member_fuzzy_search_linking_test.exs` -- `test/accounts/user_member_linking_email_test.exs` -- `test/mv_web/user_live/form_member_linking_ui_test.exs` - -### Deployment Notes -- **Assets:** Requires `cd assets && npm run build` -- **Database:** No migrations (uses existing indexes) -- **Config:** No changes required - ---- - ## Conclusion This project demonstrates a modern Phoenix application built with: @@ -1553,14 +1343,14 @@ This project demonstrates a modern Phoenix application built with: **Next Steps:** - Implement roles & permissions - Add payment tracking -- ✅ ~~Improve accessibility (WCAG 2.1 AA)~~ - Keyboard navigation implemented +- Improve accessibility (WCAG 2.1 AA) - Member self-service portal - Email communication features --- -**Document Version:** 1.2 -**Last Updated:** 2025-11-27 +**Document Version:** 1.1 +**Last Updated:** 2025-11-13 **Maintainer:** Development Team **Status:** Living Document (update as project evolves) diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 2313fd7..9a6517d 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -94,18 +94,15 @@ - ✅ 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 5b7514c..e1cf397 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -14,7 +14,6 @@ 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) @@ -60,10 +59,10 @@ defmodule Mv.Membership.CustomField do actions do defaults [:read, :update] - default_accept [:name, :value_type, :description, :immutable, :required, :show_in_overview] + default_accept [:name, :value_type, :description, :immutable, :required] create :create do - accept [:name, :value_type, :description, :immutable, :required, :show_in_overview] + accept [:name, :value_type, :description, :immutable, :required] change Mv.Membership.CustomField.Changes.GenerateSlug validate string_length(:slug, min: 1) end @@ -120,12 +119,6 @@ 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/membership/member.ex b/lib/membership/member.ex index da69861..eeb12c9 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -38,10 +38,6 @@ defmodule Mv.Membership.Member do require Ash.Query import Ash.Expr - # Module constants - @member_search_limit 10 - @default_similarity_threshold 0.2 - postgres do table "members" repo Mv.Repo @@ -156,10 +152,8 @@ defmodule Mv.Membership.Member do prepare fn query, _ctx -> q = Ash.Query.get_argument(query, :query) || "" - # Use default similarity threshold if not provided - # Lower value leads to more results but also more unspecific results - threshold = - Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold + # 0.2 as similarity threshold (recommended) - lower value can lead to more results but also to more unspecific results + threshold = Ash.Query.get_argument(query, :similarity_threshold) || 0.2 if is_binary(q) and String.trim(q) != "" do q2 = String.trim(q) @@ -193,75 +187,8 @@ defmodule Mv.Membership.Member do end end end - - # Action to find members available for linking to a user account - # Returns only unlinked members (user_id == nil), limited to 10 results - # - # Filtering behavior: - # - If search_query provided: fuzzy search on names and email - # - If no search_query: return all unlinked members (up to limit) - # - user_email should be handled by caller with filter_by_email_match/2 - read :available_for_linking do - argument :user_email, :string, allow_nil?: true - argument :search_query, :string, allow_nil?: true - - prepare fn query, _ctx -> - user_email = Ash.Query.get_argument(query, :user_email) - search_query = Ash.Query.get_argument(query, :search_query) - - query - |> Ash.Query.filter(is_nil(user)) - |> apply_linking_filters(user_email, search_query) - |> Ash.Query.limit(@member_search_limit) - end - end end - @doc """ - Filters members list based on email match priority. - - Priority logic: - 1. If email matches a member: return ONLY that member (highest priority) - 2. If email doesn't match: return all members (for display in dropdown) - - This is used with :available_for_linking action to implement email-priority behavior: - - user_email matches → Only this member - - user_email does NOT match + NO search_query → All unlinked members - - user_email does NOT match + search_query provided → search_query filtered members - - ## Parameters - - `members` - List of Member structs (from :available_for_linking action) - - `user_email` - Email string to match against member emails - - ## Returns - - List of Member structs (either single email match or all members) - - ## Examples - - iex> members = [%Member{email: "test@example.com"}, %Member{email: "other@example.com"}] - iex> filter_by_email_match(members, "test@example.com") - [%Member{email: "test@example.com"}] - - iex> filter_by_email_match(members, "nomatch@example.com") - [%Member{email: "test@example.com"}, %Member{email: "other@example.com"}] - """ - @spec filter_by_email_match([t()], String.t()) :: [t()] - def filter_by_email_match(members, user_email) - when is_list(members) and is_binary(user_email) do - email_match = Enum.find(members, &(&1.email == user_email)) - - if email_match do - # Email match found - return only this member (highest priority) - [email_match] - else - # No email match - return all members unchanged - members - end - end - - @spec filter_by_email_match(any(), any()) :: any() - def filter_by_email_match(members, _user_email), do: members - validations do # Required fields are covered by allow_nil? false @@ -434,32 +361,7 @@ defmodule Mv.Membership.Member do identity :unique_email, [:email] end - @doc """ - Performs fuzzy search on members using PostgreSQL trigram similarity. - - Wraps the `:search` action with convenient opts-based argument passing. - Searches across first_name, last_name, email, and other text fields using - full-text search combined with trigram similarity. - - ## Parameters - - `query` - Ash.Query.t() to apply search to - - `opts` - Keyword list or map with search options: - - `:query` or `"query"` - Search string - - `:fields` or `"fields"` - Optional field restrictions - - ## Returns - - Modified Ash.Query.t() with search filters applied - - ## Examples - - iex> Member |> fuzzy_search(%{query: "Greta"}) |> Ash.read!() - [%Member{first_name: "Greta", ...}] - - iex> Member |> fuzzy_search(%{query: "gre"}) |> Ash.read!() # typo-tolerant - [%Member{first_name: "Greta", ...}] - - """ - @spec fuzzy_search(Ash.Query.t(), keyword() | map()) :: Ash.Query.t() + # Fuzzy Search function that can be called by live view and calls search action def fuzzy_search(query, opts) do q = (opts[:query] || opts["query"] || "") |> to_string() @@ -475,60 +377,4 @@ defmodule Mv.Membership.Member do Ash.Query.for_read(query, :search, args) end end - - # Private helper to apply filters for :available_for_linking action - # user_email: may be nil/empty when creating new user, or populated when editing - # search_query: optional search term for fuzzy matching - # - # Logic: (email == user_email) OR (fuzzy_search on search_query) - # - Empty user_email ("") → email == "" is always false → only fuzzy search matches - # - This allows a single filter expression instead of duplicating fuzzy search logic - # - # Cyclomatic complexity is unavoidable here: PostgreSQL fuzzy search requires - # multiple OR conditions for good search quality (FTS + trigram similarity + substring) - # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity - defp apply_linking_filters(query, user_email, search_query) do - has_search = search_query && String.trim(search_query) != "" - # Use empty string instead of nil to simplify filter logic - trimmed_email = if user_email, do: String.trim(user_email), else: "" - - if has_search do - # Search query provided: return email-match OR fuzzy-search candidates - trimmed_search = String.trim(search_query) - - query - |> Ash.Query.filter( - expr( - # Email match candidate (for filter_by_email_match priority) - # If email is "", this is always false and fuzzy search takes over - # Fuzzy search candidates - email == ^trimmed_email or - fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^trimmed_search) or - fragment("search_vector @@ plainto_tsquery('simple', ?)", ^trimmed_search) or - fragment("? % first_name", ^trimmed_search) or - fragment("? % last_name", ^trimmed_search) or - fragment("word_similarity(?, first_name) > 0.2", ^trimmed_search) or - fragment( - "word_similarity(?, last_name) > ?", - ^trimmed_search, - ^@default_similarity_threshold - ) or - fragment( - "similarity(first_name, ?) > ?", - ^trimmed_search, - ^@default_similarity_threshold - ) or - fragment( - "similarity(last_name, ?) > ?", - ^trimmed_search, - ^@default_similarity_threshold - ) or - contains(email, ^trimmed_search) - ) - ) - else - # No search query: return all unlinked (filter_by_email_match will prioritize email if provided) - query - end - end end diff --git a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex index af68f96..9cea265 100644 --- a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex +++ b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex @@ -41,37 +41,18 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do if should_validate? do case Ash.Changeset.fetch_change(changeset, :email) do {:ok, new_email} -> - # Extract member_id from relationship changes for new links - member_id_to_exclude = get_member_id_from_changeset(changeset) - check_email_uniqueness(new_email, member_id_to_exclude) + check_email_uniqueness(new_email, member_id) :error -> # No email change, get current email current_email = Ash.Changeset.get_attribute(changeset, :email) - # Extract member_id from relationship changes for new links - member_id_to_exclude = get_member_id_from_changeset(changeset) - check_email_uniqueness(current_email, member_id_to_exclude) + check_email_uniqueness(current_email, member_id) end else :ok end end - # Extract member_id from changeset, checking relationship changes first - # This is crucial for new links where member_id is in manage_relationship changes - defp get_member_id_from_changeset(changeset) do - # Try to get from relationships (for new links via manage_relationship) - case Map.get(changeset.relationships, :member) do - [{[%{id: id}], _opts}] when not is_nil(id) -> - # Found in relationships - this is a new link - id - - _ -> - # Fall back to attribute (for existing links) - Ash.Changeset.get_attribute(changeset, :member_id) - end - end - defp check_email_uniqueness(email, exclude_member_id) do query = Mv.Membership.Member diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index b8fe0fc..656d3c0 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -318,13 +318,6 @@ 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 @@ -342,16 +335,6 @@ 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")} @@ -366,23 +349,6 @@ 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 99317a9..ab8f104 100644 --- a/lib/mv_web/live/custom_field_live/form.ex +++ b/lib/mv_web/live/custom_field_live/form.ex @@ -18,7 +18,6 @@ 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) @@ -61,7 +60,6 @@ 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 85ee4fb..c933133 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -26,14 +26,6 @@ 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. @@ -42,16 +34,6 @@ 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")) @@ -59,7 +41,6 @@ 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} @@ -79,8 +60,6 @@ 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) @@ -129,14 +108,7 @@ defmodule MvWeb.MemberLive.Index do """ @impl true def handle_info({:sort, field_str}, socket) do - # Handle both atom and string field names (for custom fields) - field = - try do - String.to_existing_atom(field_str) - rescue - ArgumentError -> field_str - end - + field = String.to_existing_atom(field_str) {new_field, new_order} = determine_new_sort(field, socket) socket @@ -186,38 +158,10 @@ 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 # ------------------------------------------------------------- @@ -233,8 +177,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 = to_sort_id(new_field) - old_id = to_sort_id(old_field) + active_id = :"sort_#{new_field}" + old_id = :"sort_#{old_field}" # Update the new SortHeader send_update(MvWeb.Components.SortHeaderComponent, @@ -253,32 +197,11 @@ 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" => field_str, + "sort_field" => Atom.to_string(field), "sort_order" => Atom.to_string(order) } @@ -291,24 +214,7 @@ defmodule MvWeb.MemberLive.Index do )} end - # 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. + # Load members eg based on a query for sorting defp load_members(socket, search_query) do query = Mv.Membership.Member @@ -326,71 +232,16 @@ 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 - # 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 - ) + query = maybe_sort(query, socket.assigns.sort_field, socket.assigns.sort_order) - # 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 # ------------------------------------------------------------- @@ -413,24 +264,15 @@ defmodule MvWeb.MemberLive.Index do defp toggle_order(nil), do: :asc # Function to sort the column if needed - # 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, nil, _), do: query - 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, :asc) when not is_nil(field), + do: Ash.Query.sort(query, [{field, :asc}]) - defp maybe_sort(query, _, _, _), do: {query, false} + defp maybe_sort(query, field, :desc) when not is_nil(field), + do: Ash.Query.sort(query, [{field, :desc}]) + + defp maybe_sort(query, _, _), do: query # Validate that a field is sortable defp valid_sort_field?(field) when is_atom(field) do @@ -446,188 +288,12 @@ defmodule MvWeb.MemberLive.Index do :join_date ] - field in valid_fields or custom_field_sort?(field) - end - - defp valid_sort_field?(field) when is_binary(field) do - custom_field_sort?(field) + field in valid_fields end defp valid_sort_field?(_), do: false - # 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. + # Function to maybe update the sort 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) @@ -639,50 +305,33 @@ defmodule MvWeb.MemberLive.Index do defp maybe_update_sort(socket, _), do: socket - # Determine sort field from URL parameter, validating against allowed fields - defp determine_field(default, ""), do: default - defp determine_field(default, nil), do: default + defp determine_field(default, sf) do + case sf do + "" -> + 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 + 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 end end - defp determine_field(default, sf) when is_atom(sf) do - if valid_sort_field?(sf), do: sf, else: default + defp handle_atom_conversion(val, default) when is_atom(val) do + if valid_sort_field?(val), do: val, else: default end - defp determine_field(default, _), do: default + defp handle_atom_conversion(_, 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 @@ -701,36 +350,4 @@ 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 67fa804..cb2ccd8 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -19,9 +19,6 @@ 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} > @@ -188,6 +185,7 @@ > {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 deleted file mode 100644 index 2074962..0000000 --- a/lib/mv_web/live/member_live/index/formatter.ex +++ /dev/null @@ -1,74 +0,0 @@ -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/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 9619a15..cf7b687 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -120,130 +120,6 @@ defmodule MvWeb.UserLive.Form do <% end %> <% end %>
- - -
-

{gettext("Linked Member")}

- - <%= if @user && @user.member && !@unlink_member do %> - -
-
-
-

- {@user.member.first_name} {@user.member.last_name} -

-

{@user.member.email}

-
- -
-
- <% else %> - <%= if @unlink_member do %> - -
-

- {gettext("Unlinking scheduled")}: {gettext( - "Member will be unlinked when you save. Cannot select new member until saved." - )} -

-
- <% end %> - -
-
- - - <%= if length(@available_members) > 0 do %> -
- <%= for {member, index} <- Enum.with_index(@available_members) do %> -
-

{member.first_name} {member.last_name}

-

{member.email}

-
- <% end %> -
- <% end %> -
- - <%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %> -
-

- {gettext("Note")}: {gettext( - "A member with this email already exists. To link with a different member, please change one of the email addresses first." - )} -

-
- <% end %> - - <%= if @selected_member_id && @selected_member_name do %> -
-

- {gettext("Selected")}: {@selected_member_name} -

-

- {gettext("Save to confirm linking.")} -

-
- <% end %> -
- <% end %> -
<.button phx-disable-with={gettext("Saving...")} variant="primary"> {gettext("Save User")} @@ -259,7 +135,7 @@ defmodule MvWeb.UserLive.Form do user = case params["id"] do nil -> nil - id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member]) + id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts) end action = if is_nil(user), do: gettext("New"), else: gettext("Edit") @@ -271,18 +147,9 @@ defmodule MvWeb.UserLive.Form do |> assign(user: user) |> assign(:page_title, page_title) |> assign(:show_password_fields, false) - |> assign(:member_search_query, "") - |> assign(:available_members, []) - |> assign(:show_member_dropdown, false) - |> assign(:selected_member_id, nil) - |> assign(:selected_member_name, nil) - |> assign(:unlink_member, false) - |> assign(:focused_member_index, nil) - |> load_initial_members() |> assign_form()} end - @spec return_to(String.t() | nil) :: String.t() defp return_to("show"), do: "show" defp return_to(_), do: "index" @@ -299,201 +166,28 @@ defmodule MvWeb.UserLive.Form do end def handle_event("validate", %{"user" => user_params}, socket) do - validated_form = AshPhoenix.Form.validate(socket.assigns.form, user_params) - - # Reload members if email changed (for email-match priority) - socket = - if Map.has_key?(user_params, "email") do - user_email = user_params["email"] - members = load_members_for_linking(user_email, socket.assigns.member_search_query) - - assign(socket, form: validated_form, available_members: members) - else - assign(socket, form: validated_form) - end - - {:noreply, socket} + {:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, user_params))} end def handle_event("save", %{"user" => user_params}, socket) do - # First save the user without member changes case AshPhoenix.Form.submit(socket.assigns.form, params: user_params) do {:ok, user} -> - # Then handle member linking/unlinking as a separate step - result = - cond do - # Selected member ID takes precedence (new link) - socket.assigns.selected_member_id -> - Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}}) + notify_parent({:saved, user}) - # Unlink flag is set - socket.assigns[:unlink_member] -> - Mv.Accounts.update_user(user, %{member: nil}) + socket = + socket + |> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully") + |> push_navigate(to: return_path(socket.assigns.return_to, user)) - # No changes to member relationship - true -> - {:ok, user} - end - - case result do - {:ok, updated_user} -> - notify_parent({:saved, updated_user}) - - socket = - socket - |> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully") - |> push_navigate(to: return_path(socket.assigns.return_to, updated_user)) - - {:noreply, socket} - - {:error, error} -> - # Show user-friendly error from member linking/unlinking - error_message = extract_error_message(error) - - {:noreply, - put_flash( - socket, - :error, - gettext("Failed to link member: %{error}", error: error_message) - )} - end + {:noreply, socket} {:error, form} -> {:noreply, assign(socket, form: form)} end end - def handle_event("show_member_dropdown", _params, socket) do - {:noreply, assign(socket, show_member_dropdown: true)} - end - - def handle_event("hide_member_dropdown", _params, socket) do - {:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)} - end - - def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do - return_if_dropdown_closed(socket, fn -> - max_index = length(socket.assigns.available_members) - 1 - current = socket.assigns.focused_member_index - - new_index = - case current do - nil -> 0 - index when index < max_index -> index + 1 - _ -> current - end - - {:noreply, assign(socket, focused_member_index: new_index)} - end) - end - - def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do - return_if_dropdown_closed(socket, fn -> - current = socket.assigns.focused_member_index - - new_index = - case current do - nil -> 0 - 0 -> 0 - index -> index - 1 - end - - {:noreply, assign(socket, focused_member_index: new_index)} - end) - end - - def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do - return_if_dropdown_closed(socket, fn -> - select_focused_member(socket) - end) - end - - def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do - return_if_dropdown_closed(socket, fn -> - {:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)} - end) - end - - def handle_event("member_dropdown_keydown", _params, socket) do - # Ignore other keys - {:noreply, socket} - end - - def handle_event("search_members", %{"member_search" => query}, socket) do - socket = - socket - |> assign(:member_search_query, query) - |> load_available_members(query) - |> assign(:show_member_dropdown, true) - |> assign(:focused_member_index, nil) - - {:noreply, socket} - end - - def handle_event("select_member", %{"id" => member_id}, socket) do - # Find the selected member to get their name - selected_member = Enum.find(socket.assigns.available_members, &(&1.id == member_id)) - - member_name = - if selected_member, - do: "#{selected_member.first_name} #{selected_member.last_name}", - else: "" - - # Store the selected member ID and name in socket state and clear unlink flag - socket = - socket - |> assign(:selected_member_id, member_id) - |> assign(:selected_member_name, member_name) - |> assign(:unlink_member, false) - |> assign(:show_member_dropdown, false) - |> assign(:member_search_query, member_name) - |> push_event("set-input-value", %{id: "member-search-input", value: member_name}) - - {:noreply, socket} - end - - def handle_event("unlink_member", _params, socket) do - # Set flag to unlink member on save - # Clear all member selection state and keep dropdown hidden - socket = - socket - |> assign(:unlink_member, true) - |> assign(:selected_member_id, nil) - |> assign(:selected_member_name, nil) - |> assign(:member_search_query, "") - |> assign(:show_member_dropdown, false) - |> load_initial_members() - - {:noreply, socket} - end - - @spec notify_parent(any()) :: any() defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) - # Helper to ignore keyboard events when dropdown is closed - @spec return_if_dropdown_closed(Phoenix.LiveView.Socket.t(), function()) :: - {:noreply, Phoenix.LiveView.Socket.t()} - defp return_if_dropdown_closed(socket, func) do - if socket.assigns.show_member_dropdown do - func.() - else - {:noreply, socket} - end - end - - # Select the currently focused member from the dropdown - @spec select_focused_member(Phoenix.LiveView.Socket.t()) :: - {:noreply, Phoenix.LiveView.Socket.t()} - defp select_focused_member(socket) do - with index when not is_nil(index) <- socket.assigns.focused_member_index, - member when not is_nil(member) <- Enum.at(socket.assigns.available_members, index) do - handle_event("select_member", %{"id" => member.id}, socket) - else - _ -> {:noreply, socket} - end - end - - @spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do form = if user do @@ -513,71 +207,6 @@ defmodule MvWeb.UserLive.Form do assign(socket, form: to_form(form)) end - @spec return_path(String.t(), Mv.Accounts.User.t() | nil) :: String.t() defp return_path("index", _user), do: ~p"/users" defp return_path("show", user), do: ~p"/users/#{user.id}" - - @spec load_initial_members(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() - defp load_initial_members(socket) do - user = socket.assigns.user - user_email = if user, do: user.email, else: nil - - members = load_members_for_linking(user_email, "") - - # Dropdown should ALWAYS be hidden initially - # It will only show when user focuses the input field (show_member_dropdown event) - socket - |> assign(available_members: members) - |> assign(show_member_dropdown: false) - end - - @spec load_available_members(Phoenix.LiveView.Socket.t(), String.t()) :: - Phoenix.LiveView.Socket.t() - defp load_available_members(socket, query) do - user = socket.assigns.user - user_email = if user, do: user.email, else: nil - - members = load_members_for_linking(user_email, query) - assign(socket, available_members: members) - end - - @spec load_members_for_linking(String.t() | nil, String.t() | nil) :: [Mv.Membership.Member.t()] - defp load_members_for_linking(user_email, search_query) do - user_email_str = if user_email, do: to_string(user_email), else: nil - search_query_str = if search_query && search_query != "", do: search_query, else: nil - - query = - Mv.Membership.Member - |> Ash.Query.for_read(:available_for_linking, %{ - user_email: user_email_str, - search_query: search_query_str - }) - - case Ash.read(query, domain: Mv.Membership) do - {:ok, members} -> - # Apply email match filter if user_email is provided - if user_email_str do - Mv.Membership.Member.filter_by_email_match(members, user_email_str) - else - members - end - - {:error, _} -> - [] - end - end - - # Extract user-friendly error message from Ash.Error - @spec extract_error_message(any()) :: String.t() - defp extract_error_message(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do - # Take first error and extract message - case List.first(errors) do - %{message: message} when is_binary(message) -> message - %{field: field, message: message} -> "#{field}: #{message}" - _ -> "Unknown error" - end - end - - defp extract_error_message(error) when is_binary(error), do: error - defp extract_error_message(_), do: "Unknown error" end diff --git a/lib/mv_web/live/user_live/index.ex b/lib/mv_web/live/user_live/index.ex index 0c1d7be..8803237 100644 --- a/lib/mv_web/live/user_live/index.ex +++ b/lib/mv_web/live/user_live/index.ex @@ -25,7 +25,7 @@ defmodule MvWeb.UserLive.Index do @impl true def mount(_params, _session, socket) do - users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts, load: [:member]) + users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts) sorted = Enum.sort_by(users, & &1.email) {:ok, diff --git a/lib/mv_web/live/user_live/index.html.heex b/lib/mv_web/live/user_live/index.html.heex index 3582046..66e3b9e 100644 --- a/lib/mv_web/live/user_live/index.html.heex +++ b/lib/mv_web/live/user_live/index.html.heex @@ -50,13 +50,6 @@ {user.email} <:col :let={user} label={gettext("OIDC ID")}>{user.oidc_id} - <:col :let={user} label={gettext("Linked Member")}> - <%= if user.member do %> - {user.member.first_name} {user.member.last_name} - <% else %> - {gettext("No member linked")} - <% end %> - <:action :let={user}>
diff --git a/mix.lock b/mix.lock index 77dcc09..28683a3 100644 --- a/mix.lock +++ b/mix.lock @@ -16,7 +16,7 @@ "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"}, "crux": {:hex, :crux, "0.1.1", "94f2f97d2a6079ae3c57f356412bc3b307f9579a80e43f526447b1d508dd4a72", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "e59d498f038193cbe31e448f9199f5b4c53a4c67cece9922bb839595189dd2b6"}, - "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"}, + "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "ecto": {:hex, :ecto, "3.13.3", "6a983f0917f8bdc7a89e96f2bf013f220503a0da5d8623224ba987515b3f0d80", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1927db768f53a88843ff25b6ba7946599a8ca8a055f69ad8058a1432a399af94"}, @@ -80,7 +80,7 @@ "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, - "thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"}, + "thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"}, "tidewave": {:hex, :tidewave, "0.5.0", "8f278d7eb2d0af36ae6d4f73a5872bd066815bd57b57401125187ba901f095a4", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "9a1eb5d2f12ff4912328dfbfe652c27fded462c6ed6fd11814ee28d3e9d016b4"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, diff --git a/notes.md b/notes.md deleted file mode 100644 index a5aa44f..0000000 --- a/notes.md +++ /dev/null @@ -1,58 +0,0 @@ -# User-Member Association - Test Status - -## Test Files Created/Modified - -### 1. test/membership/member_available_for_linking_test.exs (NEU) -**Status**: Alle Tests sollten FEHLSCHLAGEN ❌ -**Grund**: Die `:available_for_linking` Action existiert noch nicht - -Tests: -- ✗ returns only unlinked members and limits to 10 -- ✗ limits results to 10 members even when more exist -- ✗ email match: returns only member with matching email when exists -- ✗ email match: returns all unlinked members when no email match -- ✗ search query: filters by first_name, last_name, and email -- ✗ email match takes precedence over search query - -### 2. test/accounts/user_member_linking_test.exs (NEU) -**Status**: Tests sollten teilweise ERFOLGREICH sein ✅ / teilweise FEHLSCHLAGEN ❌ - -Tests: -- ✓ link user to member with different email syncs member email (sollte BESTEHEN - Email-Sync ist implementiert) -- ✓ unlink member from user sets member to nil (sollte BESTEHEN - Unlink ist implementiert) -- ✓ cannot link member already linked to another user (sollte BESTEHEN - Validierung existiert) -- ✓ cannot change member link directly, must unlink first (sollte BESTEHEN - Validierung existiert) - -### 3. test/mv_web/user_live/form_test.exs (ERWEITERT) -**Status**: Alle neuen Tests sollten FEHLSCHLAGEN ❌ -**Grund**: Member-Linking UI ist noch nicht implementiert - -Neue Tests: -- ✗ shows linked member with unlink button when user has member -- ✗ shows member search field when user has no member -- ✗ selecting member and saving links member to user -- ✗ unlinking member and saving removes member from user - -### 4. test/mv_web/user_live/index_test.exs (ERWEITERT) -**Status**: Neuer Test sollte FEHLSCHLAGEN ❌ -**Grund**: Member-Spalte wird noch nicht in der Index-View angezeigt - -Neuer Test: -- ✗ displays linked member name in user list - -## Zusammenfassung - -**Tests gesamt**: 13 -**Sollten BESTEHEN**: 4 (Backend-Validierungen bereits vorhanden) -**Sollten FEHLSCHLAGEN**: 9 (Features noch nicht implementiert) - -## Nächste Schritte - -1. Implementiere `:available_for_linking` Action in `lib/membership/member.ex` -2. Erstelle `MemberAutocompleteComponent` in `lib/mv_web/live/components/member_autocomplete_component.ex` -3. Integriere Member-Linking UI in `lib/mv_web/live/user_live/form.ex` -4. Füge Member-Spalte zu `lib/mv_web/live/user_live/index.ex` hinzu -5. Füge Gettext-Übersetzungen hinzu - -Nach jeder Implementierung: Tests erneut ausführen und prüfen, ob sie grün werden. - diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index f144198..a2b63c7 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -10,12 +10,12 @@ msgid "" msgstr "" "Language: en\n" -#: lib/mv_web/components/core_components.ex:356 +#: lib/mv_web/components/core_components.ex:339 #, elixir-autogen, elixir-format msgid "Actions" msgstr "Aktionen" -#: lib/mv_web/live/member_live/index.html.heex:202 +#: lib/mv_web/live/member_live/index.html.heex:200 #: lib/mv_web/live/user_live/index.html.heex:65 #, elixir-autogen, elixir-format msgid "Are you sure?" @@ -28,19 +28,19 @@ 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:148 +#: lib/mv_web/live/member_live/index.html.heex:145 #: 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:204 +#: lib/mv_web/live/member_live/index.html.heex:202 #: 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:196 +#: lib/mv_web/live/member_live/index.html.heex:194 #: lib/mv_web/live/user_live/form.ex:141 #: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format @@ -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:80 +#: lib/mv_web/live/member_live/index.html.heex:77 #: 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:182 +#: lib/mv_web/live/member_live/index.html.heex:179 #: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" @@ -87,7 +87,7 @@ msgstr "Nachname" msgid "New Member" msgstr "Neues Mitglied" -#: lib/mv_web/live/member_live/index.html.heex:193 +#: lib/mv_web/live/member_live/index.html.heex:191 #: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" @@ -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:114 +#: lib/mv_web/live/member_live/index.html.heex:111 #: 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:165 +#: lib/mv_web/live/member_live/index.html.heex:162 #: 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:131 +#: lib/mv_web/live/member_live/index.html.heex:128 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -158,17 +158,17 @@ msgstr "Postleitzahl" msgid "Save Member" msgstr "Mitglied speichern" -#: lib/mv_web/live/custom_field_live/form.ex:66 +#: lib/mv_web/live/custom_field_live/form.ex:64 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/global_settings_live.ex:55 #: lib/mv_web/live/member_live/form.ex:79 -#: lib/mv_web/live/user_live/form.ex:234 +#: lib/mv_web/live/user_live/form.ex:124 #, elixir-autogen, elixir-format msgid "Saving..." msgstr "Speichern..." #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:97 +#: lib/mv_web/live/member_live/index.html.heex:94 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -184,7 +184,6 @@ 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" @@ -200,20 +199,19 @@ 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:110 +#: lib/mv_web/live/custom_field_live/form.ex:108 #: 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:111 +#: lib/mv_web/live/custom_field_live/form.ex:109 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -255,11 +253,11 @@ 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:69 +#: lib/mv_web/live/custom_field_live/form.ex:67 #: 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 -#: lib/mv_web/live/user_live/form.ex:237 +#: lib/mv_web/live/user_live/form.ex:127 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "Abbrechen" @@ -269,7 +267,7 @@ msgstr "Abbrechen" msgid "Choose a member" msgstr "Mitglied auswählen" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Description" msgstr "Beschreibung" @@ -289,7 +287,7 @@ msgstr "Aktiviert" msgid "ID" msgstr "ID" -#: lib/mv_web/live/custom_field_live/form.ex:62 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "Unveränderlich" @@ -317,7 +315,7 @@ msgstr "Mitglied" msgid "Members" msgstr "Mitglieder" -#: lib/mv_web/live/custom_field_live/form.ex:51 +#: lib/mv_web/live/custom_field_live/form.ex:50 #, elixir-autogen, elixir-format msgid "Name" msgstr "Name" @@ -339,7 +337,6 @@ msgstr "Nicht gesetzt" #: lib/mv_web/live/user_live/form.ex:107 #: lib/mv_web/live/user_live/form.ex:115 -#: lib/mv_web/live/user_live/form.ex:210 #, elixir-autogen, elixir-format msgid "Note" msgstr "Hinweis" @@ -360,17 +357,17 @@ msgstr "Passwort-Authentifizierung" msgid "Profil" msgstr "Profil" -#: lib/mv_web/live/custom_field_live/form.ex:63 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Required" msgstr "Erforderlich" -#: lib/mv_web/live/member_live/index.html.heex:37 +#: lib/mv_web/live/member_live/index.html.heex:34 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "Alle Mitglieder auswählen" -#: lib/mv_web/live/member_live/index.html.heex:51 +#: lib/mv_web/live/member_live/index.html.heex:48 #, elixir-autogen, elixir-format msgid "Select member" msgstr "Mitglied auswählen" @@ -380,7 +377,7 @@ msgstr "Mitglied auswählen" msgid "Settings" msgstr "Einstellungen" -#: lib/mv_web/live/user_live/form.ex:235 +#: lib/mv_web/live/user_live/form.ex:125 #, elixir-autogen, elixir-format msgid "Save User" msgstr "Benutzer*in speichern" @@ -405,7 +402,7 @@ msgstr "Nicht unterstützter Wertetyp: %{type}" msgid "Use this form to manage user records in your database." msgstr "Verwenden Sie dieses Formular, um Benutzer*innen-Datensätze zu verwalten." -#: lib/mv_web/live/user_live/form.ex:252 +#: lib/mv_web/live/user_live/form.ex:142 #: lib/mv_web/live/user_live/show.ex:34 #, elixir-autogen, elixir-format msgid "User" @@ -416,7 +413,7 @@ msgstr "Benutzer*in" msgid "Value" msgstr "Wert" -#: lib/mv_web/live/custom_field_live/form.ex:56 +#: lib/mv_web/live/custom_field_live/form.ex:55 #, elixir-autogen, elixir-format msgid "Value type" msgstr "Wertetyp" @@ -433,7 +430,7 @@ msgstr "aufsteigend" msgid "descending" msgstr "absteigend" -#: lib/mv_web/live/user_live/form.ex:251 +#: lib/mv_web/live/user_live/form.ex:141 #, elixir-autogen, elixir-format msgid "New" msgstr "Neue*r" @@ -508,8 +505,6 @@ msgstr "Passwort setzen" msgid "User will be created without a password. Check 'Set Password' to add one." msgstr "Benutzer*in wird ohne Passwort erstellt. Aktivieren Sie 'Passwort setzen', um eines hinzuzufügen." -#: lib/mv_web/live/user_live/form.ex:126 -#: lib/mv_web/live/user_live/index.html.heex:53 #: lib/mv_web/live/user_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Linked Member" @@ -520,7 +515,6 @@ msgstr "Verknüpftes Mitglied" msgid "Linked User" msgstr "Verknüpfte*r Benutzer*in" -#: lib/mv_web/live/user_live/index.html.heex:57 #: lib/mv_web/live/user_live/show.ex:65 #, elixir-autogen, elixir-format msgid "No member linked" @@ -572,7 +566,7 @@ msgstr "Benutzer*innen" msgid "Click to sort" msgstr "Klicke um zu sortieren" -#: lib/mv_web/live/member_live/index.html.heex:63 +#: lib/mv_web/live/member_live/index.html.heex:60 #, elixir-autogen, elixir-format msgid "First name" msgstr "Vorname" @@ -624,7 +618,7 @@ msgstr "Benutzerdefinierte Feldwerte" msgid "Custom field" msgstr "Benutzerdefiniertes Feld" -#: lib/mv_web/live/custom_field_live/form.ex:117 +#: lib/mv_web/live/custom_field_live/form.ex:115 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "Benutzerdefiniertes Feld erfolgreich %{action}" @@ -639,7 +633,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:67 +#: lib/mv_web/live/custom_field_live/form.ex:65 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "Benutzerdefiniertes Feld speichern" @@ -649,7 +643,7 @@ msgstr "Benutzerdefiniertes Feld speichern" msgid "Save Custom field value" msgstr "Benutzerdefinierten Feldwert speichern" -#: lib/mv_web/live/custom_field_live/form.ex:46 +#: lib/mv_web/live/custom_field_live/form.ex:45 #, 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." @@ -701,59 +695,6 @@ msgstr "Obigen Text zur Bestätigung eingeben" msgid "To confirm deletion, please enter this text:" msgstr "Um die Löschung zu bestätigen, gib bitte folgenden Text ein:" -#: lib/mv_web/live/user_live/form.ex:210 -#, 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 "Ein Mitglied mit dieser E-Mail-Adresse existiert bereits. Um mit einem anderen Mitglied zu verknüpfen, ändern Sie bitte zuerst eine der E-Mail-Adressen." - -#: lib/mv_web/live/user_live/form.ex:185 -#, elixir-autogen, elixir-format -msgid "Available members" -msgstr "Verfügbare Mitglieder" - -#: 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 "Mitglied wird beim Speichern entverknüpft. Neues Mitglied kann erst nach dem Speichern ausgewählt werden." - -#: lib/mv_web/live/user_live/form.ex:226 -#, elixir-autogen, elixir-format -msgid "Save to confirm linking." -msgstr "Speichern, um die Verknüpfung zu bestätigen." - -#: lib/mv_web/live/user_live/form.ex:169 -#, elixir-autogen, elixir-format -msgid "Search for a member to link..." -msgstr "Nach einem Mitglied zum Verknüpfen suchen..." - -#: lib/mv_web/live/user_live/form.ex:173 -#, elixir-autogen, elixir-format -msgid "Search for member to link" -msgstr "Nach Mitglied zum Verknüpfen suchen" - -#: lib/mv_web/live/user_live/form.ex:223 -#, elixir-autogen, elixir-format -msgid "Selected" -msgstr "Ausgewählt" - -#: lib/mv_web/live/user_live/form.ex:143 -#, elixir-autogen, elixir-format -msgid "Unlink Member" -msgstr "Mitglied entverknüpfen" - -#: lib/mv_web/live/user_live/form.ex:152 -#, elixir-autogen, elixir-format -msgid "Unlinking scheduled" -msgstr "Entverknüpfung geplant" - -#: lib/mv_web/live/user_live/form.ex:342 -#, 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/global_settings_live.ex:51 #, elixir-autogen, elixir-format msgid "Association Name" diff --git a/priv/gettext/de/LC_MESSAGES/errors.po b/priv/gettext/de/LC_MESSAGES/errors.po index 92d3048..e0db8dd 100644 --- a/priv/gettext/de/LC_MESSAGES/errors.po +++ b/priv/gettext/de/LC_MESSAGES/errors.po @@ -155,7 +155,3 @@ msgstr "muss mindestens 8 Zeichen lang sein" msgid "is required" msgstr "ist erforderlich" - -#: lib/mv_web/live/user_live/form.ex -msgid "Failed to link member: %{error}" -msgstr "Fehler beim Verknüpfen des Mitglieds: %{error}" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index a5e9aa9..578b1b5 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -11,12 +11,12 @@ msgid "" msgstr "" -#: lib/mv_web/components/core_components.ex:356 +#: lib/mv_web/components/core_components.ex:339 #, elixir-autogen, elixir-format msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:202 +#: lib/mv_web/live/member_live/index.html.heex:200 #: lib/mv_web/live/user_live/index.html.heex:65 #, elixir-autogen, elixir-format msgid "Are you sure?" @@ -29,19 +29,19 @@ msgid "Attempting to reconnect" msgstr "" #: lib/mv_web/live/member_live/form.ex:54 -#: lib/mv_web/live/member_live/index.html.heex:148 +#: lib/mv_web/live/member_live/index.html.heex:145 #: 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:204 +#: lib/mv_web/live/member_live/index.html.heex:202 #: 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:196 +#: lib/mv_web/live/member_live/index.html.heex:194 #: lib/mv_web/live/user_live/form.ex:141 #: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format @@ -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:80 +#: lib/mv_web/live/member_live/index.html.heex:77 #: 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:182 +#: lib/mv_web/live/member_live/index.html.heex:179 #: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" @@ -88,7 +88,7 @@ msgstr "" msgid "New Member" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:193 +#: lib/mv_web/live/member_live/index.html.heex:191 #: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" @@ -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:114 +#: lib/mv_web/live/member_live/index.html.heex:111 #: 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:165 +#: lib/mv_web/live/member_live/index.html.heex:162 #: 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:131 +#: lib/mv_web/live/member_live/index.html.heex:128 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -159,17 +159,17 @@ msgstr "" msgid "Save Member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:66 +#: lib/mv_web/live/custom_field_live/form.ex:64 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/global_settings_live.ex:55 #: lib/mv_web/live/member_live/form.ex:79 -#: lib/mv_web/live/user_live/form.ex:234 +#: lib/mv_web/live/user_live/form.ex:124 #, elixir-autogen, elixir-format msgid "Saving..." msgstr "" #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:97 +#: lib/mv_web/live/member_live/index.html.heex:94 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -185,7 +185,6 @@ 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" @@ -201,20 +200,19 @@ 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:110 +#: lib/mv_web/live/custom_field_live/form.ex:108 #: 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:111 +#: lib/mv_web/live/custom_field_live/form.ex:109 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -256,11 +254,11 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:69 +#: lib/mv_web/live/custom_field_live/form.ex:67 #: 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 -#: lib/mv_web/live/user_live/form.ex:237 +#: lib/mv_web/live/user_live/form.ex:127 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "" @@ -270,7 +268,7 @@ msgstr "" msgid "Choose a member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Description" msgstr "" @@ -290,7 +288,7 @@ msgstr "" msgid "ID" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:62 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" @@ -318,7 +316,7 @@ msgstr "" msgid "Members" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:51 +#: lib/mv_web/live/custom_field_live/form.ex:50 #, elixir-autogen, elixir-format msgid "Name" msgstr "" @@ -340,7 +338,6 @@ msgstr "" #: lib/mv_web/live/user_live/form.ex:107 #: lib/mv_web/live/user_live/form.ex:115 -#: lib/mv_web/live/user_live/form.ex:210 #, elixir-autogen, elixir-format msgid "Note" msgstr "" @@ -361,17 +358,17 @@ msgstr "" msgid "Profil" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:63 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Required" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:37 +#: lib/mv_web/live/member_live/index.html.heex:34 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:51 +#: lib/mv_web/live/member_live/index.html.heex:48 #, elixir-autogen, elixir-format msgid "Select member" msgstr "" @@ -381,7 +378,7 @@ msgstr "" msgid "Settings" msgstr "" -#: lib/mv_web/live/user_live/form.ex:235 +#: lib/mv_web/live/user_live/form.ex:125 #, elixir-autogen, elixir-format msgid "Save User" msgstr "" @@ -406,7 +403,7 @@ msgstr "" msgid "Use this form to manage user records in your database." msgstr "" -#: lib/mv_web/live/user_live/form.ex:252 +#: lib/mv_web/live/user_live/form.ex:142 #: lib/mv_web/live/user_live/show.ex:34 #, elixir-autogen, elixir-format msgid "User" @@ -417,7 +414,7 @@ msgstr "" msgid "Value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:56 +#: lib/mv_web/live/custom_field_live/form.ex:55 #, elixir-autogen, elixir-format msgid "Value type" msgstr "" @@ -434,7 +431,7 @@ msgstr "" msgid "descending" msgstr "" -#: lib/mv_web/live/user_live/form.ex:251 +#: lib/mv_web/live/user_live/form.ex:141 #, elixir-autogen, elixir-format msgid "New" msgstr "" @@ -509,8 +506,6 @@ msgstr "" msgid "User will be created without a password. Check 'Set Password' to add one." msgstr "" -#: lib/mv_web/live/user_live/form.ex:126 -#: lib/mv_web/live/user_live/index.html.heex:53 #: lib/mv_web/live/user_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Linked Member" @@ -521,7 +516,6 @@ msgstr "" msgid "Linked User" msgstr "" -#: lib/mv_web/live/user_live/index.html.heex:57 #: lib/mv_web/live/user_live/show.ex:65 #, elixir-autogen, elixir-format msgid "No member linked" @@ -573,7 +567,7 @@ msgstr "" msgid "Click to sort" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:63 +#: lib/mv_web/live/member_live/index.html.heex:60 #, elixir-autogen, elixir-format msgid "First name" msgstr "" @@ -625,7 +619,7 @@ msgstr "" msgid "Custom field" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:117 +#: lib/mv_web/live/custom_field_live/form.ex:115 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "" @@ -640,7 +634,7 @@ msgstr "" msgid "Please select a custom field first" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:67 +#: lib/mv_web/live/custom_field_live/form.ex:65 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "" @@ -650,7 +644,7 @@ msgstr "" msgid "Save Custom field value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:46 +#: lib/mv_web/live/custom_field_live/form.ex:45 #, elixir-autogen, elixir-format msgid "Use this form to manage custom_field records in your database." msgstr "" @@ -702,9 +696,6 @@ msgstr "" msgid "To confirm deletion, please enter this text:" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:64 -#, elixir-autogen, elixir-format -msgid "Show in overview" #: lib/mv_web/live/global_settings_live.ex:51 #, elixir-autogen, elixir-format msgid "Association Name" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 19be444..de2633e 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -11,12 +11,12 @@ msgstr "" "Language: en\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: lib/mv_web/components/core_components.ex:356 +#: lib/mv_web/components/core_components.ex:339 #, elixir-autogen, elixir-format msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:202 +#: lib/mv_web/live/member_live/index.html.heex:200 #: lib/mv_web/live/user_live/index.html.heex:65 #, elixir-autogen, elixir-format msgid "Are you sure?" @@ -29,19 +29,19 @@ msgid "Attempting to reconnect" msgstr "" #: lib/mv_web/live/member_live/form.ex:54 -#: lib/mv_web/live/member_live/index.html.heex:148 +#: lib/mv_web/live/member_live/index.html.heex:145 #: 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:204 +#: lib/mv_web/live/member_live/index.html.heex:202 #: 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:196 +#: lib/mv_web/live/member_live/index.html.heex:194 #: lib/mv_web/live/user_live/form.ex:141 #: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format @@ -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:80 +#: lib/mv_web/live/member_live/index.html.heex:77 #: 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:182 +#: lib/mv_web/live/member_live/index.html.heex:179 #: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" @@ -88,7 +88,7 @@ msgstr "" msgid "New Member" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:193 +#: lib/mv_web/live/member_live/index.html.heex:191 #: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" @@ -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:114 +#: lib/mv_web/live/member_live/index.html.heex:111 #: 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:165 +#: lib/mv_web/live/member_live/index.html.heex:162 #: 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:131 +#: lib/mv_web/live/member_live/index.html.heex:128 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -159,17 +159,17 @@ msgstr "" msgid "Save Member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:66 +#: lib/mv_web/live/custom_field_live/form.ex:64 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/global_settings_live.ex:55 #: lib/mv_web/live/member_live/form.ex:79 -#: lib/mv_web/live/user_live/form.ex:234 +#: lib/mv_web/live/user_live/form.ex:124 #, elixir-autogen, elixir-format msgid "Saving..." msgstr "" #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:97 +#: lib/mv_web/live/member_live/index.html.heex:94 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -185,7 +185,6 @@ 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" @@ -201,20 +200,19 @@ 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:110 +#: lib/mv_web/live/custom_field_live/form.ex:108 #: 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:111 +#: lib/mv_web/live/custom_field_live/form.ex:109 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -256,11 +254,11 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:69 +#: lib/mv_web/live/custom_field_live/form.ex:67 #: 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 -#: lib/mv_web/live/user_live/form.ex:237 +#: lib/mv_web/live/user_live/form.ex:127 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "" @@ -270,7 +268,7 @@ msgstr "" msgid "Choose a member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Description" msgstr "" @@ -290,7 +288,7 @@ msgstr "" msgid "ID" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:62 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" @@ -318,7 +316,7 @@ msgstr "" msgid "Members" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:51 +#: lib/mv_web/live/custom_field_live/form.ex:50 #, elixir-autogen, elixir-format msgid "Name" msgstr "" @@ -340,7 +338,6 @@ msgstr "" #: lib/mv_web/live/user_live/form.ex:107 #: lib/mv_web/live/user_live/form.ex:115 -#: lib/mv_web/live/user_live/form.ex:210 #, elixir-autogen, elixir-format, fuzzy msgid "Note" msgstr "" @@ -361,17 +358,17 @@ msgstr "" msgid "Profil" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:63 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Required" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:37 +#: lib/mv_web/live/member_live/index.html.heex:34 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:51 +#: lib/mv_web/live/member_live/index.html.heex:48 #, elixir-autogen, elixir-format msgid "Select member" msgstr "" @@ -381,7 +378,7 @@ msgstr "" msgid "Settings" msgstr "" -#: lib/mv_web/live/user_live/form.ex:235 +#: lib/mv_web/live/user_live/form.ex:125 #, elixir-autogen, elixir-format, fuzzy msgid "Save User" msgstr "" @@ -406,7 +403,7 @@ msgstr "" msgid "Use this form to manage user records in your database." msgstr "" -#: lib/mv_web/live/user_live/form.ex:252 +#: lib/mv_web/live/user_live/form.ex:142 #: lib/mv_web/live/user_live/show.ex:34 #, elixir-autogen, elixir-format msgid "User" @@ -417,7 +414,7 @@ msgstr "" msgid "Value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:56 +#: lib/mv_web/live/custom_field_live/form.ex:55 #, elixir-autogen, elixir-format msgid "Value type" msgstr "" @@ -434,7 +431,7 @@ msgstr "" msgid "descending" msgstr "" -#: lib/mv_web/live/user_live/form.ex:251 +#: lib/mv_web/live/user_live/form.ex:141 #, elixir-autogen, elixir-format msgid "New" msgstr "" @@ -509,8 +506,6 @@ msgstr "Set Password" msgid "User will be created without a password. Check 'Set Password' to add one." msgstr "User will be created without a password. Check 'Set Password' to add one." -#: lib/mv_web/live/user_live/form.ex:126 -#: lib/mv_web/live/user_live/index.html.heex:53 #: lib/mv_web/live/user_live/show.ex:55 #, elixir-autogen, elixir-format, fuzzy msgid "Linked Member" @@ -521,7 +516,6 @@ msgstr "" msgid "Linked User" msgstr "" -#: lib/mv_web/live/user_live/index.html.heex:57 #: lib/mv_web/live/user_live/show.ex:65 #, elixir-autogen, elixir-format msgid "No member linked" @@ -573,7 +567,7 @@ msgstr "" msgid "Click to sort" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:63 +#: lib/mv_web/live/member_live/index.html.heex:60 #, elixir-autogen, elixir-format, fuzzy msgid "First name" msgstr "" @@ -625,7 +619,7 @@ msgstr "" msgid "Custom field" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:117 +#: lib/mv_web/live/custom_field_live/form.ex:115 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "" @@ -640,7 +634,7 @@ msgstr "" msgid "Please select a custom field first" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:67 +#: lib/mv_web/live/custom_field_live/form.ex:65 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "" @@ -650,7 +644,7 @@ msgstr "" msgid "Save Custom field value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:46 +#: lib/mv_web/live/custom_field_live/form.ex:45 #, elixir-autogen, elixir-format, fuzzy msgid "Use this form to manage custom_field records in your database." msgstr "" @@ -702,58 +696,6 @@ msgstr "" msgid "To confirm deletion, please enter this text:" msgstr "" -#: lib/mv_web/live/user_live/form.ex:210 -#, 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, fuzzy -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}" -msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:64 -#, elixir-autogen, elixir-format -msgid "Show in overview" #: lib/mv_web/live/global_settings_live.ex:51 #, elixir-autogen, elixir-format msgid "Association Name" diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po index e1f18de..62df4a7 100644 --- a/priv/gettext/en/LC_MESSAGES/errors.po +++ b/priv/gettext/en/LC_MESSAGES/errors.po @@ -155,7 +155,3 @@ msgstr "" msgid "is required" msgstr "" - -#: lib/mv_web/live/user_live/form.ex -msgid "Failed to link member: %{error}" -msgstr "" diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot index 5d840fe..8f522c0 100644 --- a/priv/gettext/errors.pot +++ b/priv/gettext/errors.pot @@ -152,7 +152,3 @@ msgstr "" msgid "is required" msgstr "" - -#: lib/mv_web/live/user_live/form.ex -msgid "Failed to link member: %{error}" -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 deleted file mode 100644 index 32b4801..0000000 --- a/priv/repo/migrations/20251119160509_add_show_in_overview_to_custom_fields.exs +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 718fe51..0000000 --- a/priv/resource_snapshots/repo/custom_fields/20251119160509.json +++ /dev/null @@ -1,118 +0,0 @@ -{ - "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/accounts/user_member_linking_email_test.exs b/test/accounts/user_member_linking_email_test.exs deleted file mode 100644 index d7c2817..0000000 --- a/test/accounts/user_member_linking_email_test.exs +++ /dev/null @@ -1,169 +0,0 @@ -defmodule Mv.Accounts.UserMemberLinkingEmailTest do - @moduledoc """ - Tests email validation during user-member linking. - Implements rules from docs/email-sync.md. - Tests for Issue #168, specifically Problem #4: Email validation bug. - """ - - use Mv.DataCase, async: false - - alias Mv.Accounts - alias Mv.Membership - - describe "link with same email" do - test "succeeds when user.email == member.email" do - # Create member with specific email - {:ok, member} = - Membership.create_member(%{ - first_name: "Alice", - last_name: "Johnson", - email: "alice@example.com" - }) - - # Create user with same email and link to member - result = - Accounts.create_user(%{ - email: "alice@example.com", - member: %{id: member.id} - }) - - # Should succeed without errors - assert {:ok, user} = result - assert to_string(user.email) == "alice@example.com" - - # Reload to verify link - user = Ash.load!(user, [:member], domain: Mv.Accounts) - assert user.member.id == member.id - assert user.member.email == "alice@example.com" - end - - test "no validation error triggered when updating linked pair with same email" do - # Create member - {:ok, member} = - Membership.create_member(%{ - first_name: "Bob", - last_name: "Smith", - email: "bob@example.com" - }) - - # Create user and link - {:ok, user} = - Accounts.create_user(%{ - email: "bob@example.com", - member: %{id: member.id} - }) - - # Update user (should not trigger email validation error) - result = Accounts.update_user(user, %{email: "bob@example.com"}) - - assert {:ok, updated_user} = result - assert to_string(updated_user.email) == "bob@example.com" - end - end - - describe "link with different emails" do - test "fails if member.email is used by a DIFFERENT linked user" do - # Create first user and link to a different member - {:ok, other_member} = - Membership.create_member(%{ - first_name: "Other", - last_name: "Member", - email: "other@example.com" - }) - - {:ok, _user1} = - Accounts.create_user(%{ - email: "user1@example.com", - member: %{id: other_member.id} - }) - - # Reload to ensure email sync happened - _other_member = Ash.reload!(other_member) - - # Create a NEW member with different email - {:ok, member} = - Membership.create_member(%{ - first_name: "Charlie", - last_name: "Brown", - email: "charlie@example.com" - }) - - # Try to create user2 with email that matches the linked other_member - result = - Accounts.create_user(%{ - email: "user1@example.com", - member: %{id: member.id} - }) - - # Should fail because user1@example.com is already used by other_member (which is linked to user1) - assert {:error, _error} = result - end - - test "succeeds for unique emails" do - # Create member - {:ok, member} = - Membership.create_member(%{ - first_name: "David", - last_name: "Wilson", - email: "david@example.com" - }) - - # Create user with different but unique email - result = - Accounts.create_user(%{ - email: "user@example.com", - member: %{id: member.id} - }) - - # Should succeed - assert {:ok, user} = result - - # Email sync should update member's email to match user's - user = Ash.load!(user, [:member], domain: Mv.Accounts) - assert user.member.email == "user@example.com" - end - end - - describe "edge cases" do - test "unlinking and relinking with same email works (Problem #4)" do - # This is the exact scenario from Problem #4: - # 1. Link user and member (both have same email) - # 2. Unlink them (member keeps the email) - # 3. Try to relink (validation should NOT fail) - - # Create member - {:ok, member} = - Membership.create_member(%{ - first_name: "Emma", - last_name: "Davis", - email: "emma@example.com" - }) - - # Create user and link - {:ok, user} = - Accounts.create_user(%{ - email: "emma@example.com", - member: %{id: member.id} - }) - - # Verify they are linked - user = Ash.load!(user, [:member], domain: Mv.Accounts) - assert user.member.id == member.id - assert user.member.email == "emma@example.com" - - # Unlink - {:ok, unlinked_user} = Accounts.update_user(user, %{member: nil}) - assert is_nil(unlinked_user.member_id) - - # Member still has the email after unlink - member = Ash.reload!(member) - assert member.email == "emma@example.com" - - # Relink (should work - this is Problem #4) - result = Accounts.update_user(unlinked_user, %{member: %{id: member.id}}) - - assert {:ok, relinked_user} = result - assert relinked_user.member_id == member.id - end - end -end diff --git a/test/accounts/user_member_linking_test.exs b/test/accounts/user_member_linking_test.exs deleted file mode 100644 index 1111436..0000000 --- a/test/accounts/user_member_linking_test.exs +++ /dev/null @@ -1,130 +0,0 @@ -defmodule Mv.Accounts.UserMemberLinkingTest do - @moduledoc """ - Integration tests for User-Member linking functionality. - - Tests the complete workflow of linking and unlinking members to users, - including email synchronization and validation rules. - """ - use Mv.DataCase, async: false - alias Mv.Accounts - alias Mv.Membership - - describe "User-Member Linking with Email Sync" do - test "link user to member with different email syncs member email" do - # Create user with one email - {:ok, user} = Accounts.create_user(%{email: "user@example.com"}) - - # Create member with different email - {:ok, member} = - Membership.create_member(%{ - first_name: "John", - last_name: "Doe", - email: "member@example.com" - }) - - # Link user to member - {:ok, updated_user} = Accounts.update_user(user, %{member: %{id: member.id}}) - - # Verify link exists - user_with_member = Ash.get!(Mv.Accounts.User, updated_user.id, load: [:member]) - assert user_with_member.member.id == member.id - - # Verify member email was synced to match user email - synced_member = Ash.get!(Mv.Membership.Member, member.id) - assert synced_member.email == "user@example.com" - end - - test "unlink member from user sets member to nil" do - # Create and link user and member - {:ok, user} = Accounts.create_user(%{email: "user@example.com"}) - - {:ok, member} = - Membership.create_member(%{ - first_name: "Jane", - last_name: "Smith", - email: "jane@example.com" - }) - - {:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member.id}}) - - # Verify link exists - user_with_member = Ash.get!(Mv.Accounts.User, linked_user.id, load: [:member]) - assert user_with_member.member.id == member.id - - # Unlink by setting member to nil - {:ok, unlinked_user} = Accounts.update_user(linked_user, %{member: nil}) - - # Verify link is removed - user_without_member = Ash.get!(Mv.Accounts.User, unlinked_user.id, load: [:member]) - assert is_nil(user_without_member.member) - - # Verify member still exists independently - member_still_exists = Ash.get!(Mv.Membership.Member, member.id) - assert member_still_exists.id == member.id - end - - test "cannot link member already linked to another user" do - # Create first user and link to member - {:ok, user1} = Accounts.create_user(%{email: "user1@example.com"}) - - {:ok, member} = - Membership.create_member(%{ - first_name: "Bob", - last_name: "Wilson", - email: "bob@example.com" - }) - - {:ok, _linked_user1} = Accounts.update_user(user1, %{member: %{id: member.id}}) - - # Create second user and try to link to same member - {:ok, user2} = Accounts.create_user(%{email: "user2@example.com"}) - - # Should fail because member is already linked - assert {:error, %Ash.Error.Invalid{}} = - Accounts.update_user(user2, %{member: %{id: member.id}}) - end - - test "cannot change member link directly, must unlink first" do - # Create user and link to first member - {:ok, user} = Accounts.create_user(%{email: "user@example.com"}) - - {:ok, member1} = - Membership.create_member(%{ - first_name: "Alice", - last_name: "Johnson", - email: "alice@example.com" - }) - - {:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member1.id}}) - - # Create second member - {:ok, member2} = - Membership.create_member(%{ - first_name: "Charlie", - last_name: "Brown", - email: "charlie@example.com" - }) - - # Try to directly change member link (should fail) - assert {:error, %Ash.Error.Invalid{errors: errors}} = - Accounts.update_user(linked_user, %{member: %{id: member2.id}}) - - # Verify error message mentions "Remove existing member first" - error_messages = Enum.map(errors, & &1.message) - assert Enum.any?(error_messages, &String.contains?(&1, "Remove existing member first")) - - # Two-step process: first unlink, then link new member - {:ok, unlinked_user} = Accounts.update_user(linked_user, %{member: nil}) - - # After unlinking, member1 still has the user's email - # Change member1's email to avoid conflict when relinking to member2 - {:ok, _} = Membership.update_member(member1, %{email: "alice_changed@example.com"}) - - {:ok, relinked_user} = Accounts.update_user(unlinked_user, %{member: %{id: member2.id}}) - - # Verify new link is established - user_with_new_member = Ash.get!(Mv.Accounts.User, relinked_user.id, load: [:member]) - assert user_with_new_member.member.id == member2.id - end - end -end diff --git a/test/membership/custom_field_show_in_overview_test.exs b/test/membership/custom_field_show_in_overview_test.exs deleted file mode 100644 index adac600..0000000 --- a/test/membership/custom_field_show_in_overview_test.exs +++ /dev/null @@ -1,77 +0,0 @@ -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/membership/member_available_for_linking_test.exs b/test/membership/member_available_for_linking_test.exs deleted file mode 100644 index 2f3e018..0000000 --- a/test/membership/member_available_for_linking_test.exs +++ /dev/null @@ -1,222 +0,0 @@ -defmodule Mv.Membership.MemberAvailableForLinkingTest do - @moduledoc """ - Tests for the Member.available_for_linking action. - - This action returns members that can be linked to a user account: - - Only members without existing user links (user_id == nil) - - Limited to 10 results - - Special email-match logic: if user_email matches member email, only return that member - - Optional search query filtering by name and email - """ - use Mv.DataCase, async: false - alias Mv.Membership - - describe "available_for_linking/2" do - setup do - # Create 5 unlinked members with distinct names - {:ok, member1} = - Membership.create_member(%{ - first_name: "Alice", - last_name: "Anderson", - email: "alice@example.com" - }) - - {:ok, member2} = - Membership.create_member(%{ - first_name: "Bob", - last_name: "Williams", - email: "bob@example.com" - }) - - {:ok, member3} = - Membership.create_member(%{ - first_name: "Charlie", - last_name: "Davis", - email: "charlie@example.com" - }) - - {:ok, member4} = - Membership.create_member(%{ - first_name: "Diana", - last_name: "Martinez", - email: "diana@example.com" - }) - - {:ok, member5} = - Membership.create_member(%{ - first_name: "Emma", - last_name: "Taylor", - email: "emma@example.com" - }) - - unlinked_members = [member1, member2, member3, member4, member5] - - # Create 2 linked members (with users) - {:ok, user1} = Mv.Accounts.create_user(%{email: "user1@example.com"}) - - {:ok, linked_member1} = - Membership.create_member(%{ - first_name: "Linked", - last_name: "Member1", - email: "linked1@example.com", - user: %{id: user1.id} - }) - - {:ok, user2} = Mv.Accounts.create_user(%{email: "user2@example.com"}) - - {:ok, linked_member2} = - Membership.create_member(%{ - first_name: "Linked", - last_name: "Member2", - email: "linked2@example.com", - user: %{id: user2.id} - }) - - %{ - unlinked_members: unlinked_members, - linked_members: [linked_member1, linked_member2] - } - end - - test "returns only unlinked members and limits to 10", %{ - unlinked_members: unlinked_members, - linked_members: _linked_members - } do - # Call the action without any arguments - members = - Mv.Membership.Member - |> Ash.Query.for_read(:available_for_linking, %{}) - |> Ash.read!() - - # Should return only the 5 unlinked members, not the 2 linked ones - assert length(members) == 5 - - returned_ids = Enum.map(members, & &1.id) |> MapSet.new() - expected_ids = Enum.map(unlinked_members, & &1.id) |> MapSet.new() - - assert MapSet.equal?(returned_ids, expected_ids) - - # Verify none of the returned members have a user_id - Enum.each(members, fn member -> - member_with_user = Ash.get!(Mv.Membership.Member, member.id, load: [:user]) - assert is_nil(member_with_user.user) - end) - end - - test "limits results to 10 members even when more exist" do - # Create 15 additional unlinked members (total 20 unlinked) - for i <- 6..20 do - Membership.create_member(%{ - first_name: "Extra#{i}", - last_name: "Member#{i}", - email: "extra#{i}@example.com" - }) - end - - members = - Mv.Membership.Member - |> Ash.Query.for_read(:available_for_linking, %{}) - |> Ash.read!() - - # Should be limited to 10 - assert length(members) == 10 - end - - test "email match: returns only member with matching email when exists", %{ - unlinked_members: unlinked_members - } do - # Get one of the unlinked members' email - target_member = List.first(unlinked_members) - user_email = target_member.email - - raw_members = - Mv.Membership.Member - |> Ash.Query.for_read(:available_for_linking, %{user_email: user_email}) - |> Ash.read!() - - # Apply email match filtering (sorted results come from query) - # When user_email matches, only that member should be returned - members = Mv.Membership.Member.filter_by_email_match(raw_members, user_email) - - # Should return only the member with matching email - assert length(members) == 1 - assert List.first(members).id == target_member.id - assert List.first(members).email == user_email - end - - test "email match: returns all unlinked members when no email match" do - # Use an email that doesn't match any member - non_matching_email = "nonexistent@example.com" - - raw_members = - Mv.Membership.Member - |> Ash.Query.for_read(:available_for_linking, %{user_email: non_matching_email}) - |> Ash.read!() - - # Apply email match filtering - members = Mv.Membership.Member.filter_by_email_match(raw_members, non_matching_email) - - # Should return all 5 unlinked members since no match - assert length(members) == 5 - end - - test "search query: filters by first_name, last_name, and email", %{ - unlinked_members: _unlinked_members - } do - # Search by first name - members = - Mv.Membership.Member - |> Ash.Query.for_read(:available_for_linking, %{search_query: "Alice"}) - |> Ash.read!() - - assert length(members) == 1 - assert List.first(members).first_name == "Alice" - - # Search by last name - members = - Mv.Membership.Member - |> Ash.Query.for_read(:available_for_linking, %{search_query: "Williams"}) - |> Ash.read!() - - assert length(members) == 1 - assert List.first(members).last_name == "Williams" - - # Search by email - members = - Mv.Membership.Member - |> Ash.Query.for_read(:available_for_linking, %{search_query: "charlie@"}) - |> Ash.read!() - - assert length(members) == 1 - assert List.first(members).email == "charlie@example.com" - - # Search returns empty when no matches - members = - Mv.Membership.Member - |> Ash.Query.for_read(:available_for_linking, %{search_query: "NonExistent"}) - |> Ash.read!() - - assert Enum.empty?(members) - end - - test "user_email takes precedence over search_query", %{unlinked_members: unlinked_members} do - target_member = List.first(unlinked_members) - - # Pass both email match and search query that would match different members - raw_members = - Mv.Membership.Member - |> Ash.Query.for_read(:available_for_linking, %{ - user_email: target_member.email, - search_query: "Bob" - }) - |> Ash.read!() - - # Apply email-match filter (as LiveView does) - members = Mv.Membership.Member.filter_by_email_match(raw_members, target_member.email) - - # Email takes precedence: should match target_member by email, ignoring search_query - assert length(members) == 1 - assert List.first(members).id == target_member.id - end - end -end diff --git a/test/membership/member_fuzzy_search_linking_test.exs b/test/membership/member_fuzzy_search_linking_test.exs deleted file mode 100644 index 4cbd8d9..0000000 --- a/test/membership/member_fuzzy_search_linking_test.exs +++ /dev/null @@ -1,158 +0,0 @@ -defmodule Mv.Membership.MemberFuzzySearchLinkingTest do - @moduledoc """ - Tests fuzzy search in Member.available_for_linking action. - Verifies PostgreSQL trigram matching for member search. - """ - - use Mv.DataCase, async: false - - alias Mv.Accounts - alias Mv.Membership - - describe "available_for_linking with fuzzy search" do - test "finds member despite typo" do - # Create member with specific name - {:ok, member} = - Membership.create_member(%{ - first_name: "Jonathan", - last_name: "Smith", - email: "jonathan@example.com" - }) - - # Search with typo - query = - Mv.Membership.Member - |> Ash.Query.for_read(:available_for_linking, %{ - user_email: nil, - search_query: "Jonatan" - }) - - {:ok, members} = Ash.read(query, domain: Mv.Membership) - - # Should find Jonathan despite typo - assert length(members) == 1 - assert hd(members).id == member.id - end - - test "finds member with partial match" do - # Create member - {:ok, member} = - Membership.create_member(%{ - first_name: "Alexander", - last_name: "Williams", - email: "alex@example.com" - }) - - # Search with partial - query = - Mv.Membership.Member - |> Ash.Query.for_read(:available_for_linking, %{ - user_email: nil, - search_query: "Alex" - }) - - {:ok, members} = Ash.read(query, domain: Mv.Membership) - - # Should find Alexander - assert length(members) == 1 - assert hd(members).id == member.id - end - - test "email match overrides fuzzy search" do - # Create two members - {:ok, member1} = - Membership.create_member(%{ - first_name: "John", - last_name: "Doe", - email: "john@example.com" - }) - - {:ok, _member2} = - Membership.create_member(%{ - first_name: "Jane", - last_name: "Smith", - email: "jane@example.com" - }) - - # Search with user_email that matches member1, but search_query that would match member2 - query = - Mv.Membership.Member - |> Ash.Query.for_read(:available_for_linking, %{ - user_email: "john@example.com", - search_query: "Jane" - }) - - {:ok, members} = Ash.read(query, domain: Mv.Membership) - - # Apply email filter - filtered_members = Mv.Membership.Member.filter_by_email_match(members, "john@example.com") - - # Should only return member1 (email match takes precedence) - assert length(filtered_members) == 1 - assert hd(filtered_members).id == member1.id - end - - test "limits to 10 results" do - # Create 15 members with similar names - for i <- 1..15 do - Membership.create_member(%{ - first_name: "Test#{i}", - last_name: "Member", - email: "test#{i}@example.com" - }) - end - - # Search for "Test" - query = - Mv.Membership.Member - |> Ash.Query.for_read(:available_for_linking, %{ - user_email: nil, - search_query: "Test" - }) - - {:ok, members} = Ash.read(query, domain: Mv.Membership) - - # Should return max 10 members - assert length(members) == 10 - end - - test "excludes linked members" do - # Create member and link to user - {:ok, member1} = - Membership.create_member(%{ - first_name: "Linked", - last_name: "Member", - email: "linked@example.com" - }) - - {:ok, _user} = - Accounts.create_user(%{ - email: "user@example.com", - member: %{id: member1.id} - }) - - # Create unlinked member - {:ok, member2} = - Membership.create_member(%{ - first_name: "Unlinked", - last_name: "Member", - email: "unlinked@example.com" - }) - - # Search for "Member" - query = - Mv.Membership.Member - |> Ash.Query.for_read(:available_for_linking, %{ - user_email: nil, - search_query: "Member" - }) - - {:ok, members} = Ash.read(query, domain: Mv.Membership) - - # Should only return unlinked member - member_ids = Enum.map(members, & &1.id) - refute member1.id in member_ids - assert member2.id in member_ids - 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 deleted file mode 100644 index cfe3145..0000000 --- a/test/mv_web/member_live/index_custom_fields_accessibility_test.exs +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index 25aefe5..0000000 --- a/test/mv_web/member_live/index_custom_fields_display_test.exs +++ /dev/null @@ -1,262 +0,0 @@ -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 deleted file mode 100644 index d526556..0000000 --- a/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs +++ /dev/null @@ -1,173 +0,0 @@ -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 deleted file mode 100644 index 21b0c9f..0000000 --- a/test/mv_web/member_live/index_custom_fields_sorting_test.exs +++ /dev/null @@ -1,459 +0,0 @@ -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 diff --git a/test/mv_web/user_live/form_member_dropdown_test.exs b/test/mv_web/user_live/form_member_dropdown_test.exs deleted file mode 100644 index 0e93d4d..0000000 --- a/test/mv_web/user_live/form_member_dropdown_test.exs +++ /dev/null @@ -1,149 +0,0 @@ -defmodule MvWeb.UserLive.FormMemberDropdownTest do - @moduledoc """ - UI tests for member linking dropdown visibility and email handling. - Tests dropdown behavior, visibility states, and email conflict scenarios. - Related to Issue #168. - """ - - use MvWeb.ConnCase, async: true - - import Phoenix.LiveViewTest - - alias Mv.Membership - - # Helper to setup authenticated connection for admin - defp setup_admin_conn(conn) do - conn_with_oidc_user(conn, %{email: "admin@example.com"}) - end - - describe "dropdown visibility" do - test "dropdown hidden on mount", %{conn: conn} do - conn = setup_admin_conn(conn) - {:ok, _view, html} = live(conn, ~p"/users/new") - - # Dropdown should not be visible initially - refute html =~ ~r/role="listbox"/ - end - - test "dropdown shows after focus event", %{conn: conn} do - conn = setup_admin_conn(conn) - # Create unlinked members - create_unlinked_members(3) - - {:ok, view, _html} = live(conn, ~p"/users/new") - - # Focus the member search input - view - |> element("#member-search-input") - |> render_focus() - - html = render(view) - - # Dropdown should now be visible - assert html =~ ~r/role="listbox"/ - end - - test "dropdown shows top 10 unlinked members on focus", %{conn: conn} do - conn = setup_admin_conn(conn) - # Create 15 unlinked members - _members = create_unlinked_members(15) - - {:ok, view, _html} = live(conn, ~p"/users/new") - - # Focus the member search input - view - |> element("#member-search-input") - |> render_focus() - - html = render(view) - - # Count how many member entries are shown in the dropdown - # Each member creates a div with role="option" - member_count = html |> String.split(~r/role="option"/) |> length() |> Kernel.-(1) - - # Should show exactly 10 members (limit) - assert member_count == 10 - end - end - - describe "email handling" do - test "links user and member with identical email successfully", %{conn: conn} do - conn = setup_admin_conn(conn) - - {:ok, member} = - Membership.create_member(%{ - first_name: "David", - last_name: "Miller", - email: "david@example.com" - }) - - {:ok, view, _html} = live(conn, ~p"/users/new") - - # Fill user form with same email - view - |> form("#user-form", user: %{email: "david@example.com"}) - |> render_change() - - # Focus input - view - |> element("#member-search-input") - |> render_focus() - - # Select member - view - |> element("[data-member-id='#{member.id}']") - |> render_click() - - # Submit form - view - |> form("#user-form", user: %{email: "david@example.com"}) - |> render_submit() - - # Should succeed without errors - assert_redirected(view, ~p"/users") - end - - test "shows member with same email in dropdown", %{conn: conn} do - conn = setup_admin_conn(conn) - - {:ok, _member} = - Membership.create_member(%{ - first_name: "Emma", - last_name: "Davis", - email: "emma@example.com" - }) - - {:ok, view, _html} = live(conn, ~p"/users/new") - - # Fill user form with same email - view - |> form("#user-form", user: %{email: "emma@example.com"}) - |> render_change() - - # Focus the member search to trigger loading - view - |> element("#member-search-input") - |> render_focus() - - html = render(view) - - # Should show member with matching email in dropdown - assert html =~ "Emma Davis" - assert html =~ "emma@example.com" - end - end - - # Helper functions - defp create_unlinked_members(count) do - for i <- 1..count do - {:ok, member} = - Membership.create_member(%{ - first_name: "FirstName#{i}", - last_name: "LastName#{i}", - email: "member#{i}@example.com" - }) - - member - end - end -end diff --git a/test/mv_web/user_live/form_member_search_test.exs b/test/mv_web/user_live/form_member_search_test.exs deleted file mode 100644 index b2644f3..0000000 --- a/test/mv_web/user_live/form_member_search_test.exs +++ /dev/null @@ -1,112 +0,0 @@ -defmodule MvWeb.UserLive.FormMemberSearchTest do - @moduledoc """ - UI tests for fuzzy search functionality in member linking. - Tests PostgreSQL trigram-based fuzzy search behavior. - Related to Issue #168. - """ - - use MvWeb.ConnCase, async: true - - import Phoenix.LiveViewTest - - alias Mv.Membership - - # Helper to setup authenticated connection for admin - defp setup_admin_conn(conn) do - conn_with_oidc_user(conn, %{email: "admin@example.com"}) - end - - describe "fuzzy search" do - test "finds member with exact name", %{conn: conn} do - conn = setup_admin_conn(conn) - - {:ok, _member} = - Membership.create_member(%{ - first_name: "Jonathan", - last_name: "Smith", - email: "jonathan.smith@example.com" - }) - - {:ok, view, _html} = live(conn, ~p"/users/new") - - # Type exact name - view - |> element("#member-search-input") - |> render_change(%{"member_search" => "Jonathan"}) - - html = render(view) - - assert html =~ "Jonathan" - assert html =~ "Smith" - end - - test "finds member with typo (Jon finds Jonathan)", %{conn: conn} do - conn = setup_admin_conn(conn) - - {:ok, _member} = - Membership.create_member(%{ - first_name: "Jonathan", - last_name: "Smith", - email: "jonathan.smith@example.com" - }) - - {:ok, view, _html} = live(conn, ~p"/users/new") - - # Type with typo - view - |> element("#member-search-input") - |> render_change(%{"member_search" => "Jon"}) - - html = render(view) - - # Fuzzy search should find Jonathan - assert html =~ "Jonathan" - assert html =~ "Smith" - end - - test "finds member with partial substring", %{conn: conn} do - conn = setup_admin_conn(conn) - - {:ok, _member} = - Membership.create_member(%{ - first_name: "Alexander", - last_name: "Williams", - email: "alex@example.com" - }) - - {:ok, view, _html} = live(conn, ~p"/users/new") - - # Type partial - view - |> element("#member-search-input") - |> render_change(%{"member_search" => "lex"}) - - html = render(view) - - assert html =~ "Alexander" - end - - test "shows partial match with similar names", %{conn: conn} do - conn = setup_admin_conn(conn) - - {:ok, _member} = - Membership.create_member(%{ - first_name: "Johnny", - last_name: "Doeson", - email: "johnny@example.com" - }) - - {:ok, view, _html} = live(conn, ~p"/users/new") - - # Type partial match - view - |> element("#member-search-input") - |> render_change(%{"member_search" => "John"}) - - html = render(view) - - # Should find member with similar name - assert html =~ "Johnny" - end - end -end diff --git a/test/mv_web/user_live/form_member_selection_test.exs b/test/mv_web/user_live/form_member_selection_test.exs deleted file mode 100644 index 74810df..0000000 --- a/test/mv_web/user_live/form_member_selection_test.exs +++ /dev/null @@ -1,233 +0,0 @@ -defmodule MvWeb.UserLive.FormMemberSelectionTest do - @moduledoc """ - UI tests for member selection and unlink workflow. - Tests member selection behavior and unlink process. - Related to Issue #168. - """ - - use MvWeb.ConnCase, async: true - - import Phoenix.LiveViewTest - - alias Mv.Accounts - alias Mv.Membership - - # Helper to setup authenticated connection for admin - defp setup_admin_conn(conn) do - conn_with_oidc_user(conn, %{email: "admin@example.com"}) - end - - describe "member selection" do - test "input field shows selected member name", %{conn: conn} do - conn = setup_admin_conn(conn) - - {:ok, member} = - Membership.create_member(%{ - first_name: "Alice", - last_name: "Johnson", - email: "alice@example.com" - }) - - {:ok, view, _html} = live(conn, ~p"/users/new") - - # Focus and search - view - |> element("#member-search-input") - |> render_focus() - - # Select member - view - |> element("[data-member-id='#{member.id}']") - |> render_click() - - html = render(view) - - # Input field should show member name - assert html =~ "Alice Johnson" - end - - test "confirmation box appears", %{conn: conn} do - conn = setup_admin_conn(conn) - - {:ok, member} = - Membership.create_member(%{ - first_name: "Bob", - last_name: "Williams", - email: "bob@example.com" - }) - - {:ok, view, _html} = live(conn, ~p"/users/new") - - # Focus input - view - |> element("#member-search-input") - |> render_focus() - - # Select member - view - |> element("[data-member-id='#{member.id}']") - |> render_click() - - html = render(view) - - # Confirmation box should appear - assert html =~ "Selected" - assert html =~ "Bob Williams" - assert html =~ "Save to confirm linking" - end - - test "hidden input stores member ID", %{conn: conn} do - conn = setup_admin_conn(conn) - - {:ok, member} = - Membership.create_member(%{ - first_name: "Charlie", - last_name: "Brown", - email: "charlie@example.com" - }) - - {:ok, view, _html} = live(conn, ~p"/users/new") - - # Focus input - view - |> element("#member-search-input") - |> render_focus() - - # Select member - view - |> element("[data-member-id='#{member.id}']") - |> render_click() - - # Check socket assigns (member ID should be stored) - assert view |> element("#user-form") |> has_element?() - end - end - - describe "unlink workflow" do - test "unlink hides dropdown", %{conn: conn} do - conn = setup_admin_conn(conn) - # Create user with linked member - {:ok, member} = - Membership.create_member(%{ - first_name: "Frank", - last_name: "Wilson", - email: "frank@example.com" - }) - - {:ok, user} = - Accounts.create_user(%{ - email: "frank@example.com", - member: %{id: member.id} - }) - - {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit") - - # Click unlink button - view - |> element("button[phx-click='unlink_member']") - |> render_click() - - html = render(view) - - # Dropdown should not be visible - refute html =~ ~r/role="listbox"/ - end - - test "unlink shows warning", %{conn: conn} do - conn = setup_admin_conn(conn) - # Create user with linked member - {:ok, member} = - Membership.create_member(%{ - first_name: "Grace", - last_name: "Taylor", - email: "grace@example.com" - }) - - {:ok, user} = - Accounts.create_user(%{ - email: "grace@example.com", - member: %{id: member.id} - }) - - {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit") - - # Click unlink button - view - |> element("button[phx-click='unlink_member']") - |> render_click() - - html = render(view) - - # Should show warning - assert html =~ "Unlinking scheduled" - assert html =~ "Cannot select new member until saved" - end - - test "unlink disables input", %{conn: conn} do - conn = setup_admin_conn(conn) - # Create user with linked member - {:ok, member} = - Membership.create_member(%{ - first_name: "Henry", - last_name: "Anderson", - email: "henry@example.com" - }) - - {:ok, user} = - Accounts.create_user(%{ - email: "henry@example.com", - member: %{id: member.id} - }) - - {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit") - - # Click unlink button - view - |> element("button[phx-click='unlink_member']") - |> render_click() - - html = render(view) - - # Input should be disabled - assert html =~ ~r/disabled/ - end - - test "save re-enables member selection", %{conn: conn} do - conn = setup_admin_conn(conn) - # Create user with linked member - {:ok, member} = - Membership.create_member(%{ - first_name: "Isabel", - last_name: "Martinez", - email: "isabel@example.com" - }) - - {:ok, user} = - Accounts.create_user(%{ - email: "isabel@example.com", - member: %{id: member.id} - }) - - {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit") - - # Click unlink button - view - |> element("button[phx-click='unlink_member']") - |> render_click() - - # Submit form - view - |> form("#user-form") - |> render_submit() - - # Navigate back to edit - {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit") - - html = render(view) - - # Should now show member selection input (not disabled) - assert html =~ "member-search-input" - refute html =~ "Unlinking scheduled" - end - end -end diff --git a/test/mv_web/user_live/form_test.exs b/test/mv_web/user_live/form_test.exs index b8f7313..111ff42 100644 --- a/test/mv_web/user_live/form_test.exs +++ b/test/mv_web/user_live/form_test.exs @@ -281,101 +281,4 @@ defmodule MvWeb.UserLive.FormTest do assert edit_html =~ "Change Password" end end - - describe "member linking - display" do - test "shows linked member with unlink button when user has member", %{conn: conn} do - # Create member - {:ok, member} = - Mv.Membership.create_member(%{ - first_name: "John", - last_name: "Doe", - email: "john@example.com" - }) - - # Create user linked to member - user = create_test_user(%{email: "user@example.com"}) - {:ok, _updated_user} = Mv.Accounts.update_user(user, %{member: %{id: member.id}}) - - # Load form - {:ok, view, html} = setup_live_view(conn, "/users/#{user.id}/edit") - - # Should show linked member section - assert html =~ "Linked Member" - assert html =~ "John Doe" - assert html =~ "user@example.com" - assert has_element?(view, "button[phx-click='unlink_member']") - assert html =~ "Unlink Member" - end - - test "shows member search field when user has no member", %{conn: conn} do - user = create_test_user(%{email: "user@example.com"}) - {:ok, view, html} = setup_live_view(conn, "/users/#{user.id}/edit") - - # Should show member search section - assert html =~ "Linked Member" - assert has_element?(view, "input[phx-change='search_members']") - # Should not show unlink button - refute has_element?(view, "button[phx-click='unlink_member']") - end - end - - describe "member linking - workflow" do - test "selecting member and saving links member to user", %{conn: conn} do - # Create unlinked member - {:ok, member} = - Mv.Membership.create_member(%{ - first_name: "Jane", - last_name: "Smith", - email: "jane@example.com" - }) - - # Create user without member - user = create_test_user(%{email: "user@example.com"}) - {:ok, view, _html} = setup_live_view(conn, "/users/#{user.id}/edit") - - # Select member - view |> element("div[data-member-id='#{member.id}']") |> render_click() - - # Submit form - view - |> form("#user-form", user: %{email: "user@example.com"}) - |> render_submit() - - assert_redirected(view, "/users") - - # Verify member is linked - updated_user = Ash.get!(Mv.Accounts.User, user.id, domain: Mv.Accounts, load: [:member]) - assert updated_user.member.id == member.id - end - - test "unlinking member and saving removes member from user", %{conn: conn} do - # Create member - {:ok, member} = - Mv.Membership.create_member(%{ - first_name: "Bob", - last_name: "Wilson", - email: "bob@example.com" - }) - - # Create user linked to member - user = create_test_user(%{email: "user@example.com"}) - {:ok, _} = Mv.Accounts.update_user(user, %{member: %{id: member.id}}) - - {:ok, view, _html} = setup_live_view(conn, "/users/#{user.id}/edit") - - # Click unlink button - view |> element("button[phx-click='unlink_member']") |> render_click() - - # Submit form - view - |> form("#user-form", user: %{email: "user@example.com"}) - |> render_submit() - - assert_redirected(view, "/users") - - # Verify member is unlinked - updated_user = Ash.get!(Mv.Accounts.User, user.id, domain: Mv.Accounts, load: [:member]) - assert is_nil(updated_user.member) - end - end end diff --git a/test/mv_web/user_live/index_test.exs b/test/mv_web/user_live/index_test.exs index c0b0275..6393e3b 100644 --- a/test/mv_web/user_live/index_test.exs +++ b/test/mv_web/user_live/index_test.exs @@ -410,35 +410,4 @@ defmodule MvWeb.UserLive.IndexTest do assert html =~ long_email end end - - describe "member linking display" do - test "displays linked member name in user list", %{conn: conn} do - # Create member - {:ok, member} = - Mv.Membership.create_member(%{ - first_name: "Alice", - last_name: "Johnson", - email: "alice@example.com" - }) - - # Create user linked to member - user = create_test_user(%{email: "user@example.com"}) - {:ok, _updated_user} = Mv.Accounts.update_user(user, %{member: %{id: member.id}}) - - # Create another user without member - _unlinked_user = create_test_user(%{email: "unlinked@example.com"}) - - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/users") - - # Should show linked member name - assert html =~ "Alice Johnson" - # Should show user email - assert html =~ "user@example.com" - # Should show unlinked user - assert html =~ "unlinked@example.com" - # Should show "No member linked" or similar for unlinked user - assert html =~ "No member linked" - end - end end diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex deleted file mode 100644 index 5dd14a9..0000000 --- a/test/support/fixtures.ex +++ /dev/null @@ -1,96 +0,0 @@ -defmodule Mv.Fixtures do - @moduledoc """ - Shared test fixtures for consistent test data creation. - - This module provides factory functions for creating test data across - different test suites, ensuring consistency and reducing duplication. - """ - - @doc """ - Creates a member with default or custom attributes. - - ## Parameters - - `attrs` - Map or keyword list of attributes to override defaults - - ## Returns - - Member struct - - ## Examples - - iex> member_fixture() - %Mv.Membership.Member{first_name: "Test", ...} - - iex> member_fixture(%{first_name: "Alice", email: "alice@example.com"}) - %Mv.Membership.Member{first_name: "Alice", email: "alice@example.com"} - - """ - def member_fixture(attrs \\ %{}) do - attrs - |> Enum.into(%{ - first_name: "Test", - last_name: "Member", - email: "test#{System.unique_integer([:positive])}@example.com" - }) - |> Mv.Membership.create_member() - |> case do - {:ok, member} -> member - {:error, error} -> raise "Failed to create member: #{inspect(error)}" - end - end - - @doc """ - Creates a user with default or custom attributes. - - ## Parameters - - `attrs` - Map or keyword list of attributes to override defaults - - ## Returns - - User struct - - ## Examples - - iex> user_fixture() - %Mv.Accounts.User{email: "user123@example.com"} - - iex> user_fixture(%{email: "custom@example.com"}) - %Mv.Accounts.User{email: "custom@example.com"} - - """ - def user_fixture(attrs \\ %{}) do - attrs - |> Enum.into(%{ - email: "user#{System.unique_integer([:positive])}@example.com" - }) - |> Mv.Accounts.create_user() - |> case do - {:ok, user} -> user - {:error, error} -> raise "Failed to create user: #{inspect(error)}" - end - end - - @doc """ - Creates a user linked to a member. - - ## Parameters - - `user_attrs` - Map or keyword list of user attributes - - `member_attrs` - Map or keyword list of member attributes - - ## Returns - - Tuple of {user, member} - - ## Examples - - iex> {user, member} = linked_user_member_fixture() - iex> user.member_id == member.id - true - - """ - def linked_user_member_fixture(user_attrs \\ %{}, member_attrs \\ %{}) do - member = member_fixture(member_attrs) - - user_attrs = Map.put(user_attrs, :member, %{id: member.id}) - user = user_fixture(user_attrs) - - {user, member} - end -end