diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 583f173..7fe69da 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -166,6 +166,11 @@ defmodule Mv.Membership.Member do attribute :postal_code, :string do allow_nil? true end + + attribute :search_vector, AshPostgres.Tsvector, + writable?: false, + public?: false, + select_by_default?: false end relationships do diff --git a/lib/mv_web/live/components/search_bar_component.ex b/lib/mv_web/live/components/search_bar_component.ex new file mode 100644 index 0000000..3eb5246 --- /dev/null +++ b/lib/mv_web/live/components/search_bar_component.ex @@ -0,0 +1,61 @@ +defmodule MvWeb.Components.SearchBarComponent do + @moduledoc """ + Provides the SearchBar Live-Component. + + - uses the DaisyUI search input field + - sends search_changed event to parent live view with a query + """ + use MvWeb, :live_component + + @impl true + def update(_assigns, socket) do + socket = + socket + |> assign_new(:query, fn -> "" end) + |> assign_new(:placeholder, fn -> gettext("Search...") end) + + {:ok, socket} + end + + @impl true + def render(assigns) do + ~H""" + + """ + end + + @impl true + # Function to handle the search + def handle_event("search", %{"query" => q}, socket) do + # Forward a high level message to the parent + send(self(), {:search_changed, q}) + {:noreply, assign(socket, :query, q)} + end +end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 476abd1..0a9d129 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -1,5 +1,7 @@ defmodule MvWeb.MemberLive.Index do use MvWeb, :live_view + import Ash.Expr + import Ash.Query import MvWeb.TableComponents @impl true @@ -10,12 +12,39 @@ defmodule MvWeb.MemberLive.Index do {:ok, socket |> assign(:page_title, gettext("Members")) + |> assign(:query, "") |> assign(:sort_field, :first_name) |> assign(:sort_order, :asc) |> assign(:members, sorted) |> assign(:selected_members, [])} end + # ----------------------------------------------------------------- + # Receive messages from any toolbar component + # ----------------------------------------------------------------- + + # Function to handle search + @impl true + def handle_info({:search_changed, q}, socket) do + members = + if String.trim(q) == "" do + Ash.read!(Mv.Membership.Member) + else + Mv.Membership.Member + |> filter(expr(fragment("search_vector @@ plainto_tsquery('simple', ?)", ^q))) + |> Ash.read!() + end + + {:noreply, + socket + |> assign(:query, q) + |> assign(:members, members)} + end + + # ----------------------------------------------------------------- + # Handle Events + # ----------------------------------------------------------------- + @impl true def handle_event("delete", %{"id" => id}, socket) do member = Ash.get!(Mv.Membership.Member, id) diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index ab117ee..410728a 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -8,6 +8,13 @@ + <.live_component + module={MvWeb.Components.SearchBarComponent} + id="search-bar" + query={@query} + placeholder={gettext("Search...")} + /> + <.table id="members" rows={@members} diff --git a/priv/repo/migrations/20250912085235_AddSearchVectorToMembers.exs b/priv/repo/migrations/20250912085235_AddSearchVectorToMembers.exs new file mode 100644 index 0000000..126f369 --- /dev/null +++ b/priv/repo/migrations/20250912085235_AddSearchVectorToMembers.exs @@ -0,0 +1,60 @@ +defmodule Mv.Repo.Migrations.AddSearchVectorToMembers 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(:members) do + add :search_vector, :tsvector + end + + execute(""" + CREATE INDEX members_search_vector_idx + ON members + USING GIN (search_vector) + """) + + # Eigene Trigger-Funktion mit Gewichtung + execute(""" + CREATE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$ + BEGIN + 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.birth_date::text, '')), 'C') || + setweight(to_tsvector('simple', coalesce(NEW.phone_number, '')), 'C') || + 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'); + RETURN NEW; + END + $$ LANGUAGE plpgsql; + """) + + execute(""" + CREATE TRIGGER update_search_vector + BEFORE INSERT OR UPDATE ON members + FOR EACH ROW + EXECUTE FUNCTION members_search_vector_trigger() + """) + end + + def down do + execute("DROP TRIGGER IF EXISTS update_search_vector ON members") + execute("DROP FUNCTION IF EXISTS members_search_vector_trigger()") + execute("DROP INDEX IF EXISTS members_search_vector_idx") + + alter table(:members) do + remove :search_vector + end + end +end diff --git a/priv/resource_snapshots/repo/members/20250912085235.json b/priv/resource_snapshots/repo/members/20250912085235.json new file mode 100644 index 0000000..a8b86da --- /dev/null +++ b/priv/resource_snapshots/repo/members/20250912085235.json @@ -0,0 +1,199 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"uuid_generate_v7()\")", + "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": "first_name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "email", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "birth_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "paid", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "phone_number", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "join_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "exit_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "notes", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "city", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "street", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "house_number", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "postal_code", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "search_vectors", + "type": "tsvector" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "3B162FD69B92BF8258DB56BA0CBB6108FBE996B1F7231C5F2D9EC53D956EFC75", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "members" +} \ No newline at end of file diff --git a/test/mv_web/components/search_bar_component_test.exs b/test/mv_web/components/search_bar_component_test.exs new file mode 100644 index 0000000..2c85f19 --- /dev/null +++ b/test/mv_web/components/search_bar_component_test.exs @@ -0,0 +1,33 @@ +defmodule MvWeb.Components.SearchBarComponentTest do + use MvWeb.ConnCase, async: true + import Phoenix.LiveViewTest + + describe "SearchBarComponent" do + test "renders with placeholder", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + assert has_element?(view, "input[placeholder='Search...']") + end + + test "updates query when user types", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # simulate search input and check that other members are not listed + html = + view + |> element("form[role=search]") + |> render_change(%{"query" => "Friedrich"}) + + refute html =~ "Greta" + + html = + view + |> element("form[role=search]") + |> render_change(%{"query" => "Greta"}) + + refute html =~ "Friedrich" + end + end +end diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index e3e77dc..b8b573c 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -73,4 +73,16 @@ defmodule MvWeb.MemberLive.IndexTest do assert has_element?(index_view, "#flash-group", "Member create successfully") end + + test "handle_info(:search_changed) updates assigns with search results", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + send(view.pid, {:search_changed, "Friedrich"}) + + state = :sys.get_state(view.pid) + + assert state.socket.assigns.query == "Friedrich" + assert is_list(state.socket.assigns.members) + end end