Merge pull request 'Implement full-text search for members closes #11' (#163) from feature/11-fulltext-search into main
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #163 Reviewed-by: simon <s.thiessen@local-it.org>
This commit is contained in:
commit
80b79d80cd
8 changed files with 406 additions and 0 deletions
|
|
@ -166,6 +166,11 @@ defmodule Mv.Membership.Member do
|
||||||
attribute :postal_code, :string do
|
attribute :postal_code, :string do
|
||||||
allow_nil? true
|
allow_nil? true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
attribute :search_vector, AshPostgres.Tsvector,
|
||||||
|
writable?: false,
|
||||||
|
public?: false,
|
||||||
|
select_by_default?: false
|
||||||
end
|
end
|
||||||
|
|
||||||
relationships do
|
relationships do
|
||||||
|
|
|
||||||
61
lib/mv_web/live/components/search_bar_component.ex
Normal file
61
lib/mv_web/live/components/search_bar_component.ex
Normal file
|
|
@ -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"""
|
||||||
|
<form phx-change="search" phx-target={@myself} class="flex" role="search" aria-label="Search">
|
||||||
|
<label class="input">
|
||||||
|
<svg
|
||||||
|
class="h-[1em] opacity-50"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-width="2.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
|
<path d="m21 21-4.3-4.3"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder={@placeholder}
|
||||||
|
value={@query}
|
||||||
|
name="query"
|
||||||
|
phx-debounce="300"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
defmodule MvWeb.MemberLive.Index do
|
defmodule MvWeb.MemberLive.Index do
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
import Ash.Expr
|
||||||
|
import Ash.Query
|
||||||
import MvWeb.TableComponents
|
import MvWeb.TableComponents
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -10,12 +12,39 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Members"))
|
|> assign(:page_title, gettext("Members"))
|
||||||
|
|> assign(:query, "")
|
||||||
|> assign(:sort_field, :first_name)
|
|> assign(:sort_field, :first_name)
|
||||||
|> assign(:sort_order, :asc)
|
|> assign(:sort_order, :asc)
|
||||||
|> assign(:members, sorted)
|
|> assign(:members, sorted)
|
||||||
|> assign(:selected_members, [])}
|
|> assign(:selected_members, [])}
|
||||||
end
|
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
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
member = Ash.get!(Mv.Membership.Member, id)
|
member = Ash.get!(Mv.Membership.Member, id)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,13 @@
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.Components.SearchBarComponent}
|
||||||
|
id="search-bar"
|
||||||
|
query={@query}
|
||||||
|
placeholder={gettext("Search...")}
|
||||||
|
/>
|
||||||
|
|
||||||
<.table
|
<.table
|
||||||
id="members"
|
id="members"
|
||||||
rows={@members}
|
rows={@members}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
199
priv/resource_snapshots/repo/members/20250912085235.json
Normal file
199
priv/resource_snapshots/repo/members/20250912085235.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
33
test/mv_web/components/search_bar_component_test.exs
Normal file
33
test/mv_web/components/search_bar_component_test.exs
Normal file
|
|
@ -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
|
||||||
|
|
@ -73,4 +73,16 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
assert has_element?(index_view, "#flash-group", "Member create successfully")
|
assert has_element?(index_view, "#flash-group", "Member create successfully")
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue