diff --git a/docs/database-schema-readme.md b/docs/database-schema-readme.md index 6bf11de..6e444a5 100644 --- a/docs/database-schema-readme.md +++ b/docs/database-schema-readme.md @@ -233,16 +233,22 @@ Settings (1) → MembershipFeeType (0..1) ## Full-Text Search ### Implementation -- **Trigger:** `members_search_vector_trigger()` -- **Function:** Automatically updates `search_vector` on INSERT/UPDATE +- **Trigger** on `members` (INSERT/UPDATE): runs function `members_search_vector_trigger()` +- **Trigger** on `member_groups` (INSERT/UPDATE/DELETE): `update_member_search_vector_on_member_groups_change` runs function `update_member_search_vector_from_member_groups()` - **Index Type:** GIN (Generalized Inverted Index) ### Weighted Fields - **Weight A (highest):** first_name, last_name -- **Weight B:** email, notes +- **Weight B:** email, notes, group names (from member_groups → groups) - **Weight C:** city, street, house_number, postal_code, custom_field_values - **Weight D (lowest):** join_date, exit_date +### Group Names in Search +Group names are included in the member search vector so that searching for a group name (e.g. "Vorstand") finds all members in that group: +- Group names are aggregated from `member_groups` joined with `groups` and receive weight 'B' +- The trigger `update_member_search_vector_on_member_groups_change` runs on INSERT/UPDATE/DELETE on `member_groups` and refreshes the affected member's `search_vector` +- See migration `20260217120000_add_group_names_to_member_search_vector.exs` (Issue #375) + ### Custom Field Values in Search Custom field values are automatically included in the search vector: - All custom field values (string, integer, boolean, date, email) are aggregated and added to the search vector diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 67f01c8..41b3d83 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -81,12 +81,13 @@ - ✅ User-Member linking (optional 1:1) - ✅ Email synchronization between User and Member - ✅ **Bulk email copy** - Copy selected members' email addresses to clipboard (Issue #230) -- ✅ **Groups** - Organize members into groups (PR #378, #382, closes #371, #372, 2026-01-27) +- ✅ **Groups** - Organize members into groups (PR #378, #382, #423, closes #371, #372, #374, #375, 2026-01/02) - Many-to-many relationship with groups - Groups management UI (`/groups`) - Filter and sort by groups in member list - Per-group filter in member list: one row per group with All / Yes / No (All/Alle); URL params `group_=in|not_in` - Groups displayed in member overview and detail views + - Member search includes group names (search by group name finds members in that group; search_vector + trigger on member_groups) - ✅ **CSV Import** - Import members from CSV files (PR #359, #394, #395, closes #335, #336, #338, 2026-01-27) - Member field import - Custom field value import @@ -97,6 +98,7 @@ - ✅ [#162](https://git.local-it.org/local-it/mitgliederverwaltung/issues/162) - Fuzzy and substring search (closed 2025-11-12) - ✅ [#371](https://git.local-it.org/local-it/mitgliederverwaltung/issues/371) - Add groups resource (closed 2026-01-27) - ✅ [#372](https://git.local-it.org/local-it/mitgliederverwaltung/issues/372) - Groups Admin UI (closed 2026-01-27) +- ✅ [#375](https://git.local-it.org/local-it/mitgliederverwaltung/issues/375) - Search Integration (group names in member search) (implemented 2026-02-17) - ✅ [#335](https://git.local-it.org/local-it/mitgliederverwaltung/issues/335) - CSV Import UI (closed 2026-01-27) - ✅ [#336](https://git.local-it.org/local-it/mitgliederverwaltung/issues/336) - Config for import limits (closed 2026-01-27) - ✅ [#338](https://git.local-it.org/local-it/mitgliederverwaltung/issues/338) - Custom field CSV import (closed 2026-01-27) diff --git a/docs/groups-architecture.md b/docs/groups-architecture.md index 0e59409..ca1f07b 100644 --- a/docs/groups-architecture.md +++ b/docs/groups-architecture.md @@ -975,9 +975,11 @@ Each functional unit can be implemented as a **separate issue**: ### Issue 5: Search Integration **Type:** Backend **Estimation:** 2h +**Status:** ✅ Implemented (migration `20260217120000_add_group_names_to_member_search_vector.exs`, Issue #375) + **Tasks:** - Update search vector trigger to include group names -- Extend fuzzy search to search group names +- Extend fuzzy search to search group names (via search_vector; no Elixir change needed) - Test search functionality **Acceptance Criteria:** diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index ab4ad60..411e95d 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -10,7 +10,7 @@ defmodule Mv.Membership.CustomField do ## Attributes - `name` - Unique identifier for the custom field (e.g., "phone_mobile", "birthday") - `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile") - - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`) + - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`). Immutable after creation. - `description` - Optional human-readable description - `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 @@ -28,6 +28,7 @@ defmodule Mv.Membership.CustomField do ## Constraints - Name must be unique across all custom fields - Name maximum length: 100 characters + - `value_type` cannot be changed after creation (immutable) - Deleting a custom field will cascade delete all associated custom field values ## Calculations @@ -59,7 +60,7 @@ defmodule Mv.Membership.CustomField do end actions do - defaults [:read, :update] + defaults [:read] default_accept [:name, :value_type, :description, :required, :show_in_overview] create :create do @@ -68,6 +69,19 @@ defmodule Mv.Membership.CustomField do validate string_length(:slug, min: 1) end + update :update do + accept [:name, :description, :required, :show_in_overview] + require_atomic? false + + validate fn changeset, _context -> + if Ash.Changeset.changing_attribute?(changeset, :value_type) do + {:error, field: :value_type, message: "cannot be changed after creation"} + else + :ok + end + end + end + destroy :destroy_with_values do primary? true end diff --git a/lib/mv_web/live/components/sort_header_component.ex b/lib/mv_web/live/components/sort_header_component.ex index 3817d90..d548efa 100644 --- a/lib/mv_web/live/components/sort_header_component.ex +++ b/lib/mv_web/live/components/sort_header_component.ex @@ -26,7 +26,6 @@ defmodule MvWeb.Components.SortHeaderComponent do class="btn btn-ghost select-none" phx-click="sort" phx-value-field={@field} - phx-target={@myself} data-testid={@field} > {@label} @@ -43,12 +42,6 @@ defmodule MvWeb.Components.SortHeaderComponent do """ end - @impl true - def handle_event("sort", %{"field" => field_str}, socket) do - send(self(), {:sort, field_str}) - {:noreply, socket} - end - # ------------------------------------------------- # Hilfsfunktionen für ARIA Attribute & Icon SVG # ------------------------------------------------- diff --git a/lib/mv_web/live/custom_field_live/form_component.ex b/lib/mv_web/live/custom_field_live/form_component.ex index b809a1a..f89f767 100644 --- a/lib/mv_web/live/custom_field_live/form_component.ex +++ b/lib/mv_web/live/custom_field_live/form_component.ex @@ -5,7 +5,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do ## Features - Create new custom field definitions - Edit existing custom fields - - Select value type from supported types + - Select value type from supported types (only on create; immutable after creation) - Set required flag - Real-time validation @@ -44,15 +44,50 @@ defmodule MvWeb.CustomFieldLive.FormComponent do > <.input field={@form[:name]} type="text" label={gettext("Name")} /> - <.input - field={@form[:value_type]} - type="select" - label={gettext("Value type")} - options={ - Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of] - |> Enum.map(fn type -> {MvWeb.Translations.FieldTypes.label(type), type} end) - } - /> + <%= if @custom_field do %> + <%!-- Show value_type as read-only input when editing (matches Member Field pattern) --%> +
+
+ +
+
+ <% else %> + <%!-- Show value_type as select when creating --%> + <.input + field={@form[:value_type]} + type="select" + label={gettext("Value type")} + options={ + Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[ + :one_of + ] + |> Enum.map(fn type -> {MvWeb.Translations.FieldTypes.label(type), type} end) + } + /> + <% end %> + <.input field={@form[:description]} type="text" label={gettext("Description")} /> <.input field={@form[:required]} type="checkbox" label={gettext("Required")} /> <.input @@ -85,8 +120,16 @@ defmodule MvWeb.CustomFieldLive.FormComponent do @impl true def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do + # Remove value_type from params when editing (it's immutable after creation) + cleaned_params = + if socket.assigns[:custom_field] do + Map.delete(custom_field_params, "value_type") + else + custom_field_params + end + {:noreply, - assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, custom_field_params))} + assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, cleaned_params))} end @impl true @@ -94,7 +137,15 @@ defmodule MvWeb.CustomFieldLive.FormComponent do # Actor must be passed from parent (IndexComponent); component socket has no current_user actor = socket.assigns[:actor] - case MvWeb.LiveHelpers.submit_form(socket.assigns.form, custom_field_params, actor) do + # Remove value_type from params when editing (it's immutable after creation) + cleaned_params = + if socket.assigns[:custom_field] do + Map.delete(custom_field_params, "value_type") + else + custom_field_params + end + + case MvWeb.LiveHelpers.submit_form(socket.assigns.form, cleaned_params, actor) do {:ok, custom_field} -> action = case socket.assigns.form.source.type do diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 59ee8f9..d391cd2 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -68,18 +68,15 @@ defmodule MvWeb.MemberLive.Index do # This is appropriate for initialization errors that should be visible to the user. actor = current_actor(socket) - custom_fields_visible = - Mv.Membership.CustomField - |> Ash.Query.filter(expr(show_in_overview == true)) - |> Ash.Query.sort(name: :asc) - |> Ash.read!(actor: actor) - - # Load ALL custom fields for the dropdown (to show all available fields) all_custom_fields = Mv.Membership.CustomField |> Ash.Query.sort(name: :asc) |> Ash.read!(actor: actor) + custom_fields_visible = + all_custom_fields + |> Enum.filter(& &1.show_in_overview) + # Load boolean custom fields (filtered and sorted from all_custom_fields) boolean_custom_fields = all_custom_fields @@ -163,6 +160,7 @@ defmodule MvWeb.MemberLive.Index do - `"delete"` - Removes a member from the database - `"select_member"` - Toggles individual member selection - `"select_all"` - Toggles selection of all visible members + - `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL """ @impl true def handle_event("delete", %{"id" => id}, socket) do @@ -305,6 +303,46 @@ defmodule MvWeb.MemberLive.Index do end end + @impl true + def handle_event("sort", %{"field" => 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 + + {new_field, new_order} = determine_new_sort(field, socket) + old_field = socket.assigns.sort_field + + socket = + socket + |> assign(:sort_field, new_field) + |> assign(:sort_order, new_order) + |> update_sort_components(old_field, new_field, new_order) + |> load_members() + |> update_selection_assigns() + + # URL sync - push_patch happens synchronously in the event handler + query_params = + build_query_params( + socket.assigns.query, + export_sort_field(socket.assigns.sort_field), + export_sort_order(socket.assigns.sort_order), + socket.assigns.cycle_status_filter, + socket.assigns[:group_filters], + socket.assigns.show_current_cycle, + socket.assigns.boolean_custom_field_filters + ) + |> maybe_add_field_selection( + socket.assigns[:user_field_selection], + socket.assigns[:fields_in_url?] || false + ) + + {:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)} + end + # Helper to format errors for display defp format_error(%Ash.Error.Invalid{errors: errors}) do error_messages = @@ -329,50 +367,10 @@ defmodule MvWeb.MemberLive.Index do Handles messages from child components. ## Supported messages: - - `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL - `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL - `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent - `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent """ - @impl true - def handle_info({:sort, field_str}, socket) do - # Handle both atom and string field names (for custom fields) - field = - try do - String.to_existing_atom(field_str) - rescue - ArgumentError -> field_str - end - - {new_field, new_order} = determine_new_sort(field, socket) - old_field = socket.assigns.sort_field - - socket = - socket - |> assign(:sort_field, new_field) - |> assign(:sort_order, new_order) - |> update_sort_components(old_field, new_field, new_order) - |> load_members() - |> update_selection_assigns() - - # URL sync - query_params = - build_query_params( - socket.assigns.query, - export_sort_field(socket.assigns.sort_field), - export_sort_order(socket.assigns.sort_order), - socket.assigns.cycle_status_filter, - socket.assigns[:group_filters], - socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters - ) - |> maybe_add_field_selection( - socket.assigns[:user_field_selection], - socket.assigns[:fields_in_url?] || false - ) - - {:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)} - end @impl true def handle_info({:search_changed, q}, socket) do diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 29e4a0d..31de1c2 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2617,6 +2617,11 @@ msgstr "PDF" msgid "Import" msgstr "Import" +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Value type cannot be changed after creation" +msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden." + #~ #: lib/mv_web/live/import_export_live.ex #~ #, elixir-autogen, elixir-format, fuzzy #~ msgid "Export Members (CSV)" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index eeb65fe..0ab0302 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2617,3 +2617,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Import" msgstr "" + +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Value type cannot be changed after creation" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 57e2447..199173b 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2618,6 +2618,11 @@ msgstr "" msgid "Import" msgstr "" +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Value type cannot be changed after creation" +msgstr "" + #~ #: lib/mv_web/live/import_export_live.ex #~ #, elixir-autogen, elixir-format, fuzzy #~ msgid "Export Members (CSV)" diff --git a/priv/repo/migrations/20260217120000_add_group_names_to_member_search_vector.exs b/priv/repo/migrations/20260217120000_add_group_names_to_member_search_vector.exs new file mode 100644 index 0000000..5508f16 --- /dev/null +++ b/priv/repo/migrations/20260217120000_add_group_names_to_member_search_vector.exs @@ -0,0 +1,466 @@ +defmodule Mv.Repo.Migrations.AddGroupNamesToMemberSearchVector do + @moduledoc """ + Includes group names in member search_vector for full-text search (Issue #375). + + This migration: + 1. Updates members_search_vector_trigger() to include group names (weight B) + 2. Updates update_member_search_vector_from_custom_field_value() to include group names + 3. Creates trigger on member_groups to refresh member search_vector when associations change + 4. Backfills existing members' search_vector with group names + """ + + use Ecto.Migration + + def up do + # 1. Main trigger on members: add group names to search_vector + execute(""" + CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$ + DECLARE + custom_values_text text; + groups_text text; + BEGIN + -- Aggregate all custom field values for this member + SELECT string_agg( + CASE + WHEN value ? '_union_value' THEN value->>'_union_value' + WHEN value ? 'value' THEN value->>'value' + ELSE '' + END, + ' ' + ) + INTO custom_values_text + FROM custom_field_values + WHERE member_id = NEW.id AND value IS NOT NULL; + + -- Aggregate group names for this member (weight B, same as notes/email) + SELECT string_agg(g.name, ' ') + INTO groups_text + FROM member_groups mg + JOIN groups g ON g.id = mg.group_id + WHERE mg.member_id = NEW.id; + + -- Build search_vector with member fields, custom field values, and group names + NEW.search_vector := + setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') || + setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') || + setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') || + setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') || + setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B'); + RETURN NEW; + END + $$ LANGUAGE plpgsql; + """) + + # 2. Custom field trigger: when custom_field_values change, include group names in recomputed search_vector + execute(""" + CREATE OR REPLACE FUNCTION update_member_search_vector_from_custom_field_value() RETURNS trigger AS $$ + DECLARE + member_id_val uuid; + member_first_name text; + member_last_name text; + member_email text; + member_join_date date; + member_exit_date date; + member_notes text; + member_city text; + member_street text; + member_house_number text; + member_postal_code text; + custom_values_text text; + groups_text text; + old_value_text text; + new_value_text text; + BEGIN + member_id_val := COALESCE(NEW.member_id, OLD.member_id); + + IF TG_OP = 'UPDATE' THEN + old_value_text := COALESCE( + NULLIF(OLD.value->>'_union_value', ''), + NULLIF(OLD.value->>'value', ''), + '' + ); + new_value_text := COALESCE( + NULLIF(NEW.value->>'_union_value', ''), + NULLIF(NEW.value->>'value', ''), + '' + ); + IF (old_value_text IS NOT DISTINCT FROM new_value_text) AND + (OLD.member_id IS NOT DISTINCT FROM NEW.member_id) AND + (OLD.custom_field_id IS NOT DISTINCT FROM NEW.custom_field_id) THEN + RETURN COALESCE(NEW, OLD); + END IF; + END IF; + + SELECT + first_name, + last_name, + email, + join_date, + exit_date, + notes, + city, + street, + house_number, + postal_code + INTO + member_first_name, + member_last_name, + member_email, + member_join_date, + member_exit_date, + member_notes, + member_city, + member_street, + member_house_number, + member_postal_code + FROM members + WHERE id = member_id_val; + + SELECT string_agg( + CASE + WHEN value ? '_union_value' THEN value->>'_union_value' + WHEN value ? 'value' THEN value->>'value' + ELSE '' + END, + ' ' + ) + INTO custom_values_text + FROM custom_field_values + WHERE member_id = member_id_val AND value IS NOT NULL; + + SELECT string_agg(g.name, ' ') + INTO groups_text + FROM member_groups mg + JOIN groups g ON g.id = mg.group_id + WHERE mg.member_id = member_id_val; + + UPDATE members + SET search_vector = + setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') || + setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') || + setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') || + setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') || + setweight(to_tsvector('simple', coalesce(member_house_number::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(member_postal_code::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B') + WHERE id = member_id_val; + + RETURN COALESCE(NEW, OLD); + END + $$ LANGUAGE plpgsql; + """) + + # 3. Trigger on member_groups: when associations change, refresh affected member(s) search_vector. + # On UPDATE with different member_id, refresh both OLD and NEW member so neither keeps a stale vector. + execute(""" + CREATE FUNCTION update_member_search_vector_from_member_groups() RETURNS trigger AS $$ + DECLARE + member_id_val uuid; + member_first_name text; + member_last_name text; + member_email text; + member_join_date date; + member_exit_date date; + member_notes text; + member_city text; + member_street text; + member_house_number text; + member_postal_code text; + custom_values_text text; + groups_text text; + BEGIN + FOR member_id_val IN + SELECT COALESCE(NEW.member_id, OLD.member_id) + UNION ALL + SELECT OLD.member_id + WHERE TG_OP = 'UPDATE' AND OLD.member_id IS DISTINCT FROM NEW.member_id + LOOP + SELECT + first_name, + last_name, + email, + join_date, + exit_date, + notes, + city, + street, + house_number, + postal_code + INTO + member_first_name, + member_last_name, + member_email, + member_join_date, + member_exit_date, + member_notes, + member_city, + member_street, + member_house_number, + member_postal_code + FROM members + WHERE id = member_id_val; + + SELECT string_agg( + CASE + WHEN value ? '_union_value' THEN value->>'_union_value' + WHEN value ? 'value' THEN value->>'value' + ELSE '' + END, + ' ' + ) + INTO custom_values_text + FROM custom_field_values + WHERE member_id = member_id_val AND value IS NOT NULL; + + SELECT string_agg(g.name, ' ') + INTO groups_text + FROM member_groups mg + JOIN groups g ON g.id = mg.group_id + WHERE mg.member_id = member_id_val; + + UPDATE members + SET search_vector = + setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') || + setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') || + setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') || + setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') || + setweight(to_tsvector('simple', coalesce(member_house_number::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(member_postal_code::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B') + WHERE id = member_id_val; + END LOOP; + + RETURN COALESCE(NEW, OLD); + END + $$ LANGUAGE plpgsql; + """) + + execute(""" + CREATE TRIGGER update_member_search_vector_on_member_groups_change + AFTER INSERT OR UPDATE OR DELETE ON member_groups + FOR EACH ROW + EXECUTE FUNCTION update_member_search_vector_from_member_groups() + """) + + # 4. Backfill: update all members' search_vector to include group names + execute(""" + UPDATE members m + SET search_vector = + setweight(to_tsvector('simple', coalesce(m.first_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(m.last_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(m.email, '')), 'B') || + setweight(to_tsvector('simple', coalesce(m.join_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(m.exit_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(m.notes, '')), 'B') || + setweight(to_tsvector('simple', coalesce(m.city, '')), 'C') || + setweight(to_tsvector('simple', coalesce(m.street, '')), 'C') || + setweight(to_tsvector('simple', coalesce(m.house_number::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(m.postal_code::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce( + (SELECT string_agg( + CASE + WHEN value ? '_union_value' THEN value->>'_union_value' + WHEN value ? 'value' THEN value->>'value' + ELSE '' + END, + ' ' + ) + FROM custom_field_values + WHERE member_id = m.id AND value IS NOT NULL), + '' + )), 'C') || + setweight(to_tsvector('simple', coalesce( + (SELECT string_agg(g.name, ' ') + FROM member_groups mg + JOIN groups g ON g.id = mg.group_id + WHERE mg.member_id = m.id), + '' + )), 'B') + """) + end + + def down do + execute( + "DROP TRIGGER IF EXISTS update_member_search_vector_on_member_groups_change ON member_groups" + ) + + execute("DROP FUNCTION IF EXISTS update_member_search_vector_from_member_groups()") + + # Restore members_search_vector_trigger without group names + execute(""" + CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$ + DECLARE + custom_values_text text; + BEGIN + SELECT string_agg( + CASE + WHEN value ? '_union_value' THEN value->>'_union_value' + WHEN value ? 'value' THEN value->>'value' + ELSE '' + END, + ' ' + ) + INTO custom_values_text + FROM custom_field_values + WHERE member_id = NEW.id AND value IS NOT NULL; + + NEW.search_vector := + setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') || + setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') || + setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') || + setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') || + setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C'); + RETURN NEW; + END + $$ LANGUAGE plpgsql; + """) + + # Restore update_member_search_vector_from_custom_field_value without group names + execute(""" + CREATE OR REPLACE FUNCTION update_member_search_vector_from_custom_field_value() RETURNS trigger AS $$ + DECLARE + member_id_val uuid; + member_first_name text; + member_last_name text; + member_email text; + member_join_date date; + member_exit_date date; + member_notes text; + member_city text; + member_street text; + member_house_number text; + member_postal_code text; + custom_values_text text; + old_value_text text; + new_value_text text; + BEGIN + member_id_val := COALESCE(NEW.member_id, OLD.member_id); + + IF TG_OP = 'UPDATE' THEN + old_value_text := COALESCE( + NULLIF(OLD.value->>'_union_value', ''), + NULLIF(OLD.value->>'value', ''), + '' + ); + new_value_text := COALESCE( + NULLIF(NEW.value->>'_union_value', ''), + NULLIF(NEW.value->>'value', ''), + '' + ); + IF (old_value_text IS NOT DISTINCT FROM new_value_text) AND + (OLD.member_id IS NOT DISTINCT FROM NEW.member_id) AND + (OLD.custom_field_id IS NOT DISTINCT FROM NEW.custom_field_id) THEN + RETURN COALESCE(NEW, OLD); + END IF; + END IF; + + SELECT + first_name, + last_name, + email, + join_date, + exit_date, + notes, + city, + street, + house_number, + postal_code + INTO + member_first_name, + member_last_name, + member_email, + member_join_date, + member_exit_date, + member_notes, + member_city, + member_street, + member_house_number, + member_postal_code + FROM members + WHERE id = member_id_val; + + SELECT string_agg( + CASE + WHEN value ? '_union_value' THEN value->>'_union_value' + WHEN value ? 'value' THEN value->>'value' + ELSE '' + END, + ' ' + ) + INTO custom_values_text + FROM custom_field_values + WHERE member_id = member_id_val AND value IS NOT NULL; + + UPDATE members + SET search_vector = + setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') || + setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') || + setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') || + setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') || + setweight(to_tsvector('simple', coalesce(member_house_number::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(member_postal_code::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') + WHERE id = member_id_val; + + RETURN COALESCE(NEW, OLD); + END + $$ LANGUAGE plpgsql; + """) + + # Backfill without group names + execute(""" + UPDATE members m + SET search_vector = + setweight(to_tsvector('simple', coalesce(m.first_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(m.last_name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(m.email, '')), 'B') || + setweight(to_tsvector('simple', coalesce(m.join_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(m.exit_date::text, '')), 'D') || + setweight(to_tsvector('simple', coalesce(m.notes, '')), 'B') || + setweight(to_tsvector('simple', coalesce(m.city, '')), 'C') || + setweight(to_tsvector('simple', coalesce(m.street, '')), 'C') || + setweight(to_tsvector('simple', coalesce(m.house_number::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(m.postal_code::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce( + (SELECT string_agg( + CASE + WHEN value ? '_union_value' THEN value->>'_union_value' + WHEN value ? 'value' THEN value->>'value' + ELSE '' + END, + ' ' + ) + FROM custom_field_values + WHERE member_id = m.id AND value IS NOT NULL), + '' + )), 'C') + """) + end +end diff --git a/test/membership/custom_field_validation_test.exs b/test/membership/custom_field_validation_test.exs index d0711ad..e642d82 100644 --- a/test/membership/custom_field_validation_test.exs +++ b/test/membership/custom_field_validation_test.exs @@ -8,6 +8,7 @@ defmodule Mv.Membership.CustomFieldValidationTest do - Description length validation (max 500 characters) - Description trimming - Required vs optional fields + - Value type immutability (cannot be changed after creation) """ use Mv.DataCase, async: true @@ -207,4 +208,101 @@ defmodule Mv.Membership.CustomFieldValidationTest do assert [%{field: :value_type}] = changeset.errors end end + + describe "value_type immutability" do + test "rejects attempt to change value_type after creation", %{actor: actor} do + # Create custom field with value_type :string + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string + }) + |> Ash.create(actor: actor) + + original_value_type = custom_field.value_type + assert original_value_type == :string + + # Attempt to update value_type to :integer + assert {:error, %Ash.Error.Invalid{} = error} = + custom_field + |> Ash.Changeset.for_update(:update, %{ + value_type: :integer + }) + |> Ash.update(actor: actor) + + # Verify error message contains expected text + error_message = Exception.message(error) + assert error_message =~ "cannot be changed" or error_message =~ "value_type" + + # Reload and verify value_type remained unchanged + reloaded = Ash.get!(CustomField, custom_field.id, actor: actor) + assert reloaded.value_type == original_value_type + assert reloaded.value_type == :string + end + + test "allows updating other fields while value_type remains unchanged", %{actor: actor} do + # Create custom field with value_type :string + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string, + description: "Original description" + }) + |> Ash.create(actor: actor) + + original_value_type = custom_field.value_type + assert original_value_type == :string + + # Update other fields (name, description) without touching value_type + {:ok, updated_custom_field} = + custom_field + |> Ash.Changeset.for_update(:update, %{ + name: "updated_name", + description: "Updated description" + }) + |> Ash.update(actor: actor) + + # Verify value_type remained unchanged + assert updated_custom_field.value_type == original_value_type + assert updated_custom_field.value_type == :string + # Verify other fields were updated + assert updated_custom_field.name == "updated_name" + assert updated_custom_field.description == "Updated description" + end + + test "rejects value_type change even when other fields are updated", %{actor: actor} do + # Create custom field with value_type :boolean + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :boolean + }) + |> Ash.create(actor: actor) + + original_value_type = custom_field.value_type + assert original_value_type == :boolean + + # Attempt to update both name and value_type + assert {:error, %Ash.Error.Invalid{} = error} = + custom_field + |> Ash.Changeset.for_update(:update, %{ + name: "updated_name", + value_type: :date + }) + |> Ash.update(actor: actor) + + # Verify error message + error_message = Exception.message(error) + assert error_message =~ "cannot be changed" or error_message =~ "value_type" + + # Reload and verify value_type remained unchanged, but name was not updated either + reloaded = Ash.get!(CustomField, custom_field.id, actor: actor) + assert reloaded.value_type == original_value_type + assert reloaded.value_type == :boolean + assert reloaded.name == "test_field" + end + end end diff --git a/test/membership/member_search_groups_integration_test.exs b/test/membership/member_search_groups_integration_test.exs new file mode 100644 index 0000000..f6d7aa8 --- /dev/null +++ b/test/membership/member_search_groups_integration_test.exs @@ -0,0 +1,386 @@ +defmodule Mv.Membership.MemberSearchGroupsIntegrationTest do + @moduledoc """ + Tests for member search integration with group names (Issue #375). + + Verifies that: + - Group names are included in member search (via search_vector / FTS) + - Searching by group name returns all members in that group + - Search vector updates when member-group associations change (trigger on member_groups) + - Edge cases (multiple groups, no groups, special characters) and authorization + + Implementation: search_vector trigger and trigger on member_groups + (see migration 20260217120000_add_group_names_to_member_search_vector.exs, Issue #375). + """ + use Mv.DataCase, async: false + + alias Mv.Helpers.SystemActor + alias Mv.Membership.{Group, Member, MemberGroup} + + setup do + system_actor = SystemActor.get_system_actor() + %{system_actor: system_actor} + end + + describe "search by group name" do + test "search by group name finds member in that group", %{system_actor: actor} do + {:ok, member} = + Mv.Membership.create_member( + %{first_name: "Anna", last_name: "Arbeiter", email: "anna@example.com"}, + actor: actor + ) + + {:ok, group} = + Group + |> Ash.Changeset.for_create(:create, %{name: "Vorstand"}) + |> Ash.create(actor: actor) + + {:ok, _mg} = + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id}) + |> Ash.create(actor: actor) + + results = + Member + |> Member.fuzzy_search(%{query: "Vorstand"}) + |> Ash.read!(actor: actor) + + assert length(results) == 1 + assert List.first(results).id == member.id + end + + test "search by group name finds all members in that group", %{system_actor: actor} do + {:ok, m1} = + Mv.Membership.create_member( + %{first_name: "Bob", last_name: "Brown", email: "bob1@example.com"}, + actor: actor + ) + + {:ok, m2} = + Mv.Membership.create_member( + %{first_name: "Beth", last_name: "Blue", email: "beth@example.com"}, + actor: actor + ) + + {:ok, group} = + Group + |> Ash.Changeset.for_create(:create, %{name: "Board Members"}) + |> Ash.create(actor: actor) + + for member <- [m1, m2] do + {:ok, _} = + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id}) + |> Ash.create(actor: actor) + end + + results = + Member + |> Member.fuzzy_search(%{query: "Board Members"}) + |> Ash.read!(actor: actor) + + ids = Enum.map(results, & &1.id) + assert m1.id in ids + assert m2.id in ids + assert length(results) == 2 + end + + test "member in multiple groups is findable by any of those group names", %{ + system_actor: actor + } do + {:ok, member} = + Mv.Membership.create_member( + %{first_name: "Clara", last_name: "Clark", email: "clara@example.com"}, + actor: actor + ) + + {:ok, g1} = + Group + |> Ash.Changeset.for_create(:create, %{name: "Alpha Team"}) + |> Ash.create(actor: actor) + + {:ok, g2} = + Group + |> Ash.Changeset.for_create(:create, %{name: "Beta Team"}) + |> Ash.create(actor: actor) + + for {m, g} <- [{member, g1}, {member, g2}] do + {:ok, _} = + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: m.id, group_id: g.id}) + |> Ash.create(actor: actor) + end + + for group_name <- ["Alpha Team", "Beta Team"] do + results = + Member + |> Member.fuzzy_search(%{query: group_name}) + |> Ash.read!(actor: actor) + + assert Enum.any?(results, fn r -> r.id == member.id end), + "Search for #{group_name} should find member" + end + end + + test "search by group name does not return members not in that group", %{ + system_actor: actor + } do + {:ok, member_in_x} = + Mv.Membership.create_member( + %{first_name: "Xavier", last_name: "X", email: "xavier@example.com"}, + actor: actor + ) + + {:ok, member_in_y} = + Mv.Membership.create_member( + %{first_name: "Yvonne", last_name: "Y", email: "yvonne@example.com"}, + actor: actor + ) + + {:ok, group_x} = + Group + |> Ash.Changeset.for_create(:create, %{name: "GroupXOnly"}) + |> Ash.create(actor: actor) + + {:ok, group_y} = + Group + |> Ash.Changeset.for_create(:create, %{name: "GroupYOnly"}) + |> Ash.create(actor: actor) + + {:ok, _} = + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member_in_x.id, group_id: group_x.id}) + |> Ash.create(actor: actor) + + {:ok, _} = + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member_in_y.id, group_id: group_y.id}) + |> Ash.create(actor: actor) + + results_x = + Member + |> Member.fuzzy_search(%{query: "GroupXOnly"}) + |> Ash.read!(actor: actor) + + assert Enum.any?(results_x, fn r -> r.id == member_in_x.id end) + refute Enum.any?(results_x, fn r -> r.id == member_in_y.id end) + + results_y = + Member + |> Member.fuzzy_search(%{query: "GroupYOnly"}) + |> Ash.read!(actor: actor) + + assert Enum.any?(results_y, fn r -> r.id == member_in_y.id end) + refute Enum.any?(results_y, fn r -> r.id == member_in_x.id end) + end + + test "member with no groups is not found by unrelated group name", %{system_actor: actor} do + {:ok, member} = + Mv.Membership.create_member( + %{first_name: "Solo", last_name: "User", email: "solo@example.com"}, + actor: actor + ) + + {:ok, _group} = + Group + |> Ash.Changeset.for_create(:create, %{name: "SomeOtherGroup"}) + |> Ash.create(actor: actor) + + # Member is not in any group; search for the group name should not return this member + results = + Member + |> Member.fuzzy_search(%{query: "SomeOtherGroup"}) + |> Ash.read!(actor: actor) + + refute Enum.any?(results, fn r -> r.id == member.id end) + end + end + + describe "search vector update on member_groups changes" do + test "adding member to group updates search vector (INSERT on member_groups)", %{ + system_actor: actor + } do + {:ok, member} = + Mv.Membership.create_member( + %{first_name: "New", last_name: "Member", email: "new@example.com"}, + actor: actor + ) + + {:ok, group} = + Group + |> Ash.Changeset.for_create(:create, %{name: "NewlyAddedGroup"}) + |> Ash.create(actor: actor) + + # Before adding to group, search should not find by group name + results_before = + Member + |> Member.fuzzy_search(%{query: "NewlyAddedGroup"}) + |> Ash.read!(actor: actor) + + refute Enum.any?(results_before, fn r -> r.id == member.id end) + + {:ok, _mg} = + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id}) + |> Ash.create(actor: actor) + + # After adding, search should find member (trigger on member_groups INSERT) + results_after = + Member + |> Member.fuzzy_search(%{query: "NewlyAddedGroup"}) + |> Ash.read!(actor: actor) + + assert Enum.any?(results_after, fn r -> r.id == member.id end) + end + + test "removing member from group updates search vector (DELETE on member_groups)", %{ + system_actor: actor + } do + # Use a member name that does not overlap with the group name so that the only + # way to find them is via search_vector (group name). Otherwise trigram fuzzy + # match on first_name would still find "Remove" when searching "RemovedGroup". + {:ok, member} = + Mv.Membership.create_member( + %{first_name: "Zara", last_name: "None", email: "zara.remove@example.com"}, + actor: actor + ) + + {:ok, group} = + Group + |> Ash.Changeset.for_create(:create, %{name: "RemovedGroup"}) + |> Ash.create(actor: actor) + + {:ok, mg} = + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id}) + |> Ash.create(actor: actor) + + results_before = + Member + |> Member.fuzzy_search(%{query: "RemovedGroup"}) + |> Ash.read!(actor: actor) + + assert Enum.any?(results_before, fn r -> r.id == member.id end) + + :ok = Mv.Membership.destroy_member_group(mg, actor: actor) + + results_after = + Member + |> Member.fuzzy_search(%{query: "RemovedGroup"}) + |> Ash.read!(actor: actor) + + refute Enum.any?(results_after, fn r -> r.id == member.id end) + end + end + + describe "edge cases" do + test "token match: single word in group name matches (e.g. Board in Board Members)", %{ + system_actor: actor + } do + {:ok, member} = + Mv.Membership.create_member( + %{first_name: "Partial", last_name: "Test", email: "partial@example.com"}, + actor: actor + ) + + {:ok, group} = + Group + |> Ash.Changeset.for_create(:create, %{name: "Board Members"}) + |> Ash.create(actor: actor) + + {:ok, _mg} = + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id}) + |> Ash.create(actor: actor) + + # FTS with 'simple' config: full word "Board" or "Members" should match + results = + Member + |> Member.fuzzy_search(%{query: "Board"}) + |> Ash.read!(actor: actor) + + assert Enum.any?(results, fn r -> r.id == member.id end), + "Search for 'Board' should find member in group 'Board Members'" + end + + test "search with token from group name containing special characters does not crash", %{ + system_actor: actor + } do + {:ok, member} = + Mv.Membership.create_member( + %{first_name: "Special", last_name: "Char", email: "special@example.com"}, + actor: actor + ) + + {:ok, group} = + Group + |> Ash.Changeset.for_create(:create, %{name: "Team A&B"}) + |> Ash.create(actor: actor) + + {:ok, _mg} = + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id}) + |> Ash.create(actor: actor) + + # Search for a token from the group name; proves tokenization does not crash on "A&B" + results = + Member + |> Member.fuzzy_search(%{query: "Team"}) + |> Ash.read!(actor: actor) + + assert Enum.any?(results, fn r -> r.id == member.id end), + "Search for 'Team' should find member in group 'Team A&B'" + end + end + + describe "authorization" do + test "search respects authorization (actor sees only allowed members)", %{ + system_actor: system_actor + } do + # own_data user linked to member1 can only read member1; member2 is in same group + admin = Mv.Fixtures.user_with_role_fixture("admin") + user_own_data = Mv.Fixtures.user_with_role_fixture("own_data") + + member1 = + Mv.Fixtures.member_fixture(%{ + first_name: "Linked", + last_name: "User", + email: "linked@example.com" + }) + + member2 = + Mv.Fixtures.member_fixture(%{ + first_name: "Other", + last_name: "User", + email: "other@example.com" + }) + + {:ok, user_own_data} = + user_own_data + |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.force_change_attribute(:member_id, member1.id) + |> Ash.update(actor: admin) + + {:ok, group} = + Group + |> Ash.Changeset.for_create(:create, %{name: "SharedGroupName"}) + |> Ash.create(actor: system_actor) + + for member <- [member1, member2] do + {:ok, _} = + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id}) + |> Ash.create(actor: admin) + end + + # Search as own_data user: should only return member1 (linked), not member2 + results = + Member + |> Member.fuzzy_search(%{query: "SharedGroupName"}) + |> Ash.read!(actor: user_own_data) + + ids = Enum.map(results, & &1.id) + assert member1.id in ids + refute member2.id in ids + end + end +end diff --git a/test/mv_web/components/sort_header_component_test.exs b/test/mv_web/components/sort_header_component_test.exs index 6d23ab4..bdde4ae 100644 --- a/test/mv_web/components/sort_header_component_test.exs +++ b/test/mv_web/components/sort_header_component_test.exs @@ -223,7 +223,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end describe "component behavior" do - test "clicking sends sort message to parent", %{conn: conn} do + test "clicking triggers sort event on parent LiveView", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") @@ -232,7 +232,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do |> element("button[phx-value-field='first_name']") |> render_click() - # The component should send a message to the parent LiveView + # The component triggers a "sort" event on the parent LiveView # This is tested indirectly through the URL change in integration tests end diff --git a/test/mv_web/member_live/index_groups_integration_test.exs b/test/mv_web/member_live/index_groups_integration_test.exs index 3075d54..86738da 100644 --- a/test/mv_web/member_live/index_groups_integration_test.exs +++ b/test/mv_web/member_live/index_groups_integration_test.exs @@ -8,16 +8,19 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do - Groups sorting works with other sortings - Groups work with Membership Fee Status filter - Groups work with existing search (but not testing search integration itself) + - Member index search by group name returns members in that group (Issue #375) """ # async: false to prevent PostgreSQL deadlocks when creating members and groups use MvWeb.ConnCase, async: false import Phoenix.LiveViewTest require Ash.Query - alias Mv.Membership.{Group, MemberGroup, CustomField, CustomFieldValue} + alias Mv.Helpers.SystemActor + alias Mv.Membership.{CustomField, CustomFieldValue, Group, MemberGroup} + alias Mv.MembershipFees.{MembershipFeeCycle, MembershipFeeType} setup do - system_actor = Mv.Helpers.SystemActor.get_system_actor() + system_actor = SystemActor.get_system_actor() # Create test members {:ok, member1} = @@ -80,15 +83,10 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/members") - # Verify groups column is visible by default + # Verify groups column is visible by default (header and content) assert html =~ group1.name assert html =~ member1.first_name - - # Hide groups column via field visibility dropdown - # (This tests integration with field visibility feature) - # Note: Actual implementation depends on how field visibility works - # For now, we verify the column exists and can be toggled - assert html + assert html =~ "Groups" end test "groups filter works with custom field filters", %{ @@ -140,11 +138,11 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do member1: member1, group1: group1 } do - system_actor = Mv.Helpers.SystemActor.get_system_actor() + system_actor = SystemActor.get_system_actor() # Create a membership fee type and cycle for member1 {:ok, fee_type} = - Mv.MembershipFees.MembershipFeeType + MembershipFeeType |> Ash.Changeset.for_create(:create, %{ name: "Test Fee", amount: Decimal.new("50.00"), @@ -159,7 +157,7 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do ) {:ok, _cycle} = - Mv.MembershipFees.MembershipFeeCycle + MembershipFeeCycle |> Ash.Changeset.for_create(:create, %{ member_id: member1.id, membership_fee_type_id: fee_type.id, @@ -212,6 +210,25 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do # (that's part of Issue #5 - Search Integration) end + test "member index search by group name returns members in that group", %{ + conn: conn, + member1: member1, + member2: member2, + group1: group1 + } do + # member1 is in group1 "Board Members", member2 is not + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + view + |> element("form[phx-submit='search']") + |> render_submit(%{"query" => group1.name}) + + html = render(view) + assert html =~ member1.first_name + refute html =~ member2.first_name + end + test "all filters and sortings work together", %{ conn: conn, member1: member1, diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 4f36795..53a2815 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -1,5 +1,5 @@ defmodule MvWeb.MemberLive.IndexTest do - use MvWeb.ConnCase, async: true + use MvWeb.ConnCase, async: false import Phoenix.LiveViewTest require Ash.Query